uk_ct_… contact session. Your backend needs to know who is
calling it, and asking us on every request would put our latency and our uptime in
front of your API.
So the contact session buys a short-lived JWT, signed per environment, that
your backend verifies offline against a public key set.
Minting
When the session itself ends
The opaque session lives 30 days from the moment it was minted. There is no sliding renewal: the date does not move because somebody kept using the product, and no route extends it. When it runs out, the answer is to mint a new one. The SDK does not discover this on the next call — it says so when it happens, even in a tab that is only sitting open:subscribe publishes the signedOut too, and that is what a UI should listen to —
it only has to draw whoever is there now. onSessionExpired is for the code that
has to do something about it, and for that code the two ways a session ends are
opposite instructions: re-authenticating somebody who signed out is a loop, and
re-authenticating somebody whose thirty days elapsed is the entire fix. So it never
fires on a signOut().
Renewing before it ends, not after
A federated host should answeronSessionExpiring instead, which is the same
event five minutes earlier — while the session is still current:
track() queues durably and flushes every
five seconds, stamping each batch with whatever session exists at that moment, so
a renewal that happens after the expiry sends everything in the gap anonymous —
/v1/events demotes rather than refuses, so nothing fails and nothing warns. The
gap is as long as your code takes to notice, and a backgrounded tab makes it
longer.
Replaying the claim is legitimate: the hash is an HMAC over the external id and
carries no clock of its own, so it says while your app still vouches for this
person exactly what it said at load. A host that has signed them out since answers
with signOut() instead.
It is announced once per session — a tab restored with four minutes left hears it
at once rather than never — and never for a session that has already run out,
which is onSessionExpired’s moment and would otherwise have you boot twice for
one session. Pass { marginMs } if your identity call needs more room.
Each mint records the contact against the month’s active-contact meter,
idempotently. That is the number your bill reads — see
the meter.
What the token carries
string
The environment id. Pin it. A test-environment token must not satisfy a live
check.It is the
environment.id from GET /v1/me,
the call your secret key should already be making at start-up — keep the value as
USERKIT_ENVIRONMENT_ID. The environments listing is not the place to get it:
that one belongs to the panel and refuses an API key. The panel shows the same id
under Settings → API keys, beside the publishable key.uuid
The contact id.
uuid
The session id — what you pass to
DELETE /v1/contact/sessions/{id}.string
Your own id for this person, present when the contact has one. In federated mode
this is what your backend keys on.
string
boolean
Whether the identity behind the session was proven. Gate on this before
anything another person’s data could leak through — a
verified: false token is
a perfectly valid signature over an unproven claim.uuid
The team this call is acting inside — the one the client named in
X-Customer-Id when it asked for the token, or the contact’s oldest
membership when it named none. Absent for a contact who belongs to no
customer, which is every B2C contact and every contact on their first day.Absent means “no team”, never “no need to check”. A customer id the contact
does not belong to is refused with 404 at the mint rather than producing a
token silently missing the claim your backend gates on.string
The
key of the role this person holds in that team. owner and member
are in every environment; your organization’s own roles sit beside them, so
this is an open string and not a union of two values.string[]
What that role grants, and what your backend should gate on — not the
role’s name. Two vocabularies in one list: entries beginning with
$ are ours
and are the only ones UserKit evaluates ($team.manage, $billing.manage,
$keys.manage, $webhooks.manage); everything else is yours, stored and
returned here and never read by us.This is the claim that makes grades:write an offline decision: a role added
in the panel reaches your gate with no deploy of yours. See
Customer teams.boolean
Whether somebody from your support desk is holding this session rather
than the person it names. The contact is real and the identity was proven —
that is what “see what the customer sees” means — so serve the request; but a
destructive act performed through it is being performed by support, and only
your own product knows which of its acts those are.Always present,
false included, unlike the team claims. The asymmetry is
the point: an absent claim would have to be read as “not impersonated”, which
is an assertion made out of an absence — and it is the reassuring direction,
the one that stops somebody checking. false is a statement a verifier can
act on; nothing is not.integer
Unix seconds. The lifetime is 5 minutes.
The team travels with the mint, not with the session
The team claims follow what this mint asked for, which is why switching teams means minting again.@userkit/js does it for you: setActiveCustomer
throws the cached JWT away along with the choice, so the next getToken()
mints one naming the new team.
There is no active team on the server: there the mint is getToken({ customerId })
from @userkit/nextjs, and the id is the one your route already read, usually
out of the URL. Without it the answer is the oldest membership — a real team,
and rarely the one the page is showing.
Verifying
Fetch the key set once, cache it, and verify locally.Cache the key set
The response carries:POST /v1/contact/token is where you get the 501 jwt_unavailable.
What the two stale directives buy, and the rest of what happens to your product on
a bad minute of ours, is in
Availability and degradation.
Revocation
A JWT is verified offline, so nothing can recall one. Deleting a session stops the next refresh, not the token already in a page — which on its own makes five minutes the longest a revoked session keeps working. Five minutes is the floor, not the answer. The answer is a second document, used exactly like the key set: fetched on a schedule, cached, checked locally.revoked against the token’s sid. Ending a session lands there in the same
statement that deletes it, so every door — sign-out, revoking a device, “sign out
everywhere else”, support revoking a device — publishes without anything having
to remember to.
integer
The bound you get. A revoked session stops verifying within this many seconds
instead of within the token’s full lifetime.
integer
How far back the document reaches. A copy older than this says nothing about the
tokens alive now — discard it rather than trust it.
With @userkit/nextjs
Signature and revocation, both offline, in one call. Nothing here reaches us on
the request path.
claims.contactId, claims.verified,
claims.impersonated and the team’s — claims.customerId,
claims.customerRole and claims.customerPermissions, which is always an
array (empty for a contact with no team), so a gate is one includes with
nothing in front of it:
claims.revocationCheck tells you which of the three cases you are in — fresh,
stale (the last copy, still inside its window) or unavailable (accepted on
signature and expiry alone). Pass requireRevocationCheck: true to refuse on
unavailable for the handful of actions where that is the right trade; leave it
off everywhere else.
Rolling your own
Sessions the contact can see
The contact session token lives on your domain withouthttpOnly. That makes “see
my devices and end one” part of the token’s safety case, not a nice-to-have.
404.
Sessions only. Ending a device does not cancel an outstanding magic link or
email code — that is a statement about the inbox, not about a laptop, and
somebody halfway through a sign-in in another tab should not lose it. Those die
on their own clock: an hour for the link, ten minutes for the code.
Rotating the signing key
api_keys:write — the same gate as the identity secret, because this key
mints the tokens your backend trusts.
The old key stops signing immediately and keeps verifying for 24 hours: the
overlap that lets JWKS caches refresh and in-flight tokens expire. Your backend
needs no deploy — it refetches the key set and finds both kids.
For a suspected compromise the arithmetic runs the other way: every token signed
with the old key is dead within its 5-minute lifetime of the rotation. That is the
bound to quote.
The active-contact meter
customers:read. A contact counts as active for a month when a JWT is
minted for them — so a session opened in January and refreshed in February is
active in both.
Live environment only, because only live contacts count at all. The month is
UTC, decided once in the schema: display may translate it, a bill may not be
ambiguous. This reads the same query the bill reads.