Skip to main content
Your pages hold a 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.
Two credentials, two jobs. The opaque session is long-lived and revocable. The JWT is short-lived, which is exactly why it can be verified offline — and revoking one is a second cached document rather than a call, so it costs your backend nothing per request. See Revocation.

Minting

This is the refresh flow, and there is no second credential to manage: the opaque session is the refresh token. Call again whenever the last JWT is near expiry, for as long as the session lives.

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 answer onSessionExpiring instead, which is the same event five minutes earlier — while the session is still current:
The five minutes are the whole point. 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.
If you mint by hand, invalidate your cache when the team changes. A five-minute token still naming the previous team is a 403 with no cause on screen for anybody navigating between teams client-side — and it disappears on a reload, which is the worst shape a bug can have.
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.
Addressed by publishable key — the one identifier your backend already holds in config. Public and unauthenticated by design: these are public keys. There is no Origin gate either, because the caller is a server and servers send no Origin.
Verify the signature. Do not read the payload without it — a JWT is base64, not encryption, and anything can be typed into one.

Cache the key set

The response carries:
That is deliberate. A verifier has to keep verifying through our bad five minutes, so a CDN or an in-process cache should keep answering while a refetch is retried. Most JWKS clients honour this for you; the ones above do. Keys are minted on first read, so the document is never empty for a configured environment. When JWT minting is unavailable on the server, the answer is an empty key set rather than an error — the shape stays valid, and 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.
Match 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.
The list only carries revocations from the last window, because an older one names no token anybody could still present. That is what keeps it a short document you can poll rather than a certificate revocation list that grows forever.
When you cannot fetch it, keep verifying. A list that refuses every request whenever it is unreachable is a worse failure than the one it prevents. The correct degrade is back to the bound that always existed — the token’s own few minutes — and never to “nobody is logged in”.

With @userkit/nextjs

Signature and revocation, both offline, in one call. Nothing here reaches us on the request path.
The claims arrive named: 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

The only two rules: cache it (this is a poll, not a per-request call), and treat a failed fetch as “I cannot revoke faster than the token expires” rather than as a refusal.

Sessions the contact can see

The contact session token lives on your domain without httpOnly. That makes “see my devices and end one” part of the token’s safety case, not a nice-to-have.
Revocation is deletion, and the same statement publishes the id to the revocation list — so it reaches the JWT already minted, not only the next refresh. “Sign out everywhere else” keeps the session that asked. Another contact’s session id and one that never existed answer the same 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

Requires 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

Requires 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.