Tokens & sessions
ID tokens, access tokens, and refresh tokens issued by SenseCrypt — ES256 signing, the sub claim, scopes, audiences, permissions, refresh-token rotation and families, and logout.
SenseCrypt issues standard OIDC tokens. This page describes what they contain, how to validate them, and how sessions and logout work. All tokens are signed per tenant with ES256 (see Multi-tenancy); verify signatures against the tenant's jwks_uri, selecting the key by kid.
Verifying any token
Every JWT SenseCrypt issues has a header of the form:
{ "alg": "ES256", "kid": "<jwk-thumbprint>", "typ": "JWT" }The kid is the RFC 7638 thumbprint of the signing public key. To verify:
- Fetch the tenant's JWKS from
jwks_uri. Each key is an EC P-256 key:{"kty": "EC", "crv": "P-256", "x": "...", "y": "...", "use": "sig", "alg": "ES256", "kid": "..."}. - Select the key whose
kidmatches the token header. Tolerate multiple keys — during a rotation grace window the JWKS publishes more than one. - Verify the ES256 signature, then check the claims (
iss,aud,exp, and any others relevant to the token type).
Cache the JWKS, but be prepared to refetch on an unknown kid (a rotation just happened). Never pin a single key.
ID token
The ID token is an ES256 JWT returned from the authorization_code exchange. Validate its signature and check iss, aud (your client_id), and exp. A representative decoded payload:
{
"iss": "https://acme.example.com",
"sub": "b1e6…c9",
"aud": "your-client-id",
"exp": 1893456000,
"iat": 1893455400,
"auth_time": 1893455390,
"amr": ["face", "mfa", "pop"],
"acr": "urn:sensecrypt:face-aal3",
"nonce": "the-nonce-you-sent",
"name": "Ada Lovelace",
"email": "ada@acme.com"
}Claim by claim:
sub— a stable, per-tenant pseudonymous identifier for the user. It is stable across sessions and safe to use as your primary key. It is not the internal database row id.iss— the tenant's issuer URL. This tells you which tenant issued the token; verify against that issuer's JWKS.aud— yourclient_id.auth_time— when the original face ceremony happened. This is pinned to the original sign-in and does not advance on refresh. It is also what themax_ageauthorize parameter is checked against: requestmax_ageto force a fresh re-authentication (step-up), and readauth_timeto see how recently the user physically authenticated.amr— always["face", "mfa", "pop"]: a fresh on-device face match (face), multi-factor (mfa), and a device-bound key proof-of-possession (pop).acr—urn:sensecrypt:face-aal3, a SenseCrypt-defined value asserting the face ceremony. It is project-defined, not a standards-registered value — treat it as a SenseCrypt-specific constant, not an interoperable AAL claim.nonce— echoed only when you supplied one on the authorize request.- Profile claims — released according to the granted scopes (for example
name,email). Absent when no scope releases them or the user has no value.
The ID token carries no permissions, scope, jti, or azp — those live on the access token.
Access token
The access token is an ES256 JWT. Its base claims are iss, sub, aud, exp, iat, scope, token_use: "access", and jti (for individual revocation). Its exact shape depends on whether you request an API audience:
| Request | aud | azp | permissions |
|---|---|---|---|
| No audience (default) | your client_id | absent | absent |
With an API audience, enforce_rbac = false | the resource server's audience | your client_id | absent (pure audience binding) |
With an API audience, enforce_rbac = true | the resource server's audience | your client_id | the user's effective permissions (possibly []) |
You request an API audience with ?audience= (Auth0 spelling) or ?resource= (RFC 8707); resource wins if both are sent. A representative API-scoped access token with RBAC:
{
"iss": "https://acme.example.com",
"sub": "b1e6…c9",
"aud": "https://api.acme.com",
"azp": "your-client-id",
"exp": 1893459000,
"iat": 1893455400,
"scope": "openid profile email",
"token_use": "access",
"jti": "3f2a…",
"sid": "d4c1…",
"permissions": ["read:invoices", "write:invoices"]
}sidis present when a refresh token was issued; it binds the token to its refresh-token family, so revoking the family makes outstanding access tokens introspect as inactive immediately.token_use: "access"distinguishes access tokens from ID tokens on the wire.
Your API validates the JWT against the tenant's JWKS, checks aud matches its own audience, and authorizes requests against permissions. SenseCrypt only emits permissions; it never enforces them for you. See Authorization for the resource-server / role model. You can also call the UserInfo endpoint with the access token to retrieve the scope-released user claims.
Refresh token
- A refresh token is issued only when you request the
offline_accessscope at the authorization step. It comes back in the token response alongside the access and ID tokens. - Refresh tokens are opaque (not JWTs — do not try to parse them). Present one at the token endpoint with
grant_type=refresh_tokento get a new access token:
curl -X POST {token_endpoint} \
-d grant_type=refresh_token \
-d refresh_token={refresh_token} \
-d client_id={client_id}
# + your client authentication if confidentialRotation and families
Every refresh token belongs to a family created at the original sign-in. Depending on the app's configuration (and always on for SPA clients), refresh-token rotation is enabled: each use marks the presented token spent and mints a successor in the same family.
- Reuse of a spent token is treated as a breach. Presenting an already-rotated token outside a short, per-app overlap window revokes the entire family before failing. A stolen-then-replayed token therefore takes down the whole chain rather than granting quiet access. (Inside the tiny overlap window a re-presentation is tolerated once, to survive a lost response, but it can never fan out into parallel chains.)
- The family has an absolute lifetime fixed at issuance. Rotation propagates that ceiling unchanged — a stolen-then-rotated token can never outlive it. Idle-timeout, when enabled, is recomputed on each use.
The access gate re-runs on every refresh
This is the most important operational property of refresh: the access gate is re-evaluated on every refresh, and permissions are recomputed fresh. A user who has been suspended, deleted, un-enrolled, or removed from the app's groups fails closed on their next refresh — you don't wait for the access token to expire. Likewise, role and group changes take effect on the next access token, not at expiry. This makes refresh the mechanism by which lifecycle and authorization changes propagate.
All refresh failures collapse to the opaque invalid_grant — expired, revoked, unknown, and reused are indistinguishable to the caller.
Sessions and logout
Three distinct session layers sit behind every sign-in — the on-device authenticator session, the SenseCrypt IdP session, and your relying-party session — and only tokens ever cross between them:
SenseCrypt has no server-side SSO cookie — every sign-in is a fresh on-device face ceremony, so there is no browser session at the IdP to reuse or clear. "Logging out" therefore means invalidating tokens:
- Revocation (RFC 7009) revokes a refresh token's family; a matching access token's
jtiis denylisted until it expires. Always returns an empty200. See the revocation reference. - RP-Initiated Logout revokes the subject's refresh tokens for your client. Because there is no session cookie to clear, an
id_token_hintis required — it identifies the subject to act on. Anypost_logout_redirect_urimust be on your application's dedicated allow-list (separate from your/authorizeredirect list); otherwise the redirect is refused (never an open redirect). See the logout reference.
Because access tokens are self-contained JWTs, they remain valid until exp unless explicitly revoked (via revocation, logout, or a lifecycle event that kills the family). Keep access-token lifetimes short and refresh as needed. Eager revocation on lifecycle events (suspend, delete, group-member removal) severs both device keys and refresh-token families immediately — access is not left to TTL expiry.
Gotchas
- Don't cache a single JWKS key. Rotation publishes multiple keys; select by
kidand refetch on an unknown one. acris SenseCrypt-specific.urn:sensecrypt:face-aal3is not a registered AAL value; don't map it to a standard assurance level.auth_timedoesn't move on refresh. If you need "how recently did the user physically authenticate",auth_timeanswers it — and it stays pinned to the original ceremony through every refresh.offline_accessis required for refresh. Nooffline_access, no refresh token — you'll re-run the full ceremony instead.- A revoked family invalidates access tokens too. Via the
sid→family link, revoking a refresh family makes its access tokens introspect as inactive even beforeexp.
Related
- OIDC & OAuth 2.0 — the flows that mint these tokens.
- Authorization — scopes, claims release, and the
permissionsclaim. - Multi-tenancy — per-tenant keys and the
kidselection rule.
Multi-tenancy & issuers
Each SenseCrypt tenant is its own OIDC issuer and SAML IdP on its own hostname, with its own signing keys and a hard isolation boundary. Resolve every endpoint from discovery.
Authorization
How SenseCrypt decides who may sign in (the default-closed group-to-app access gate) and what end users may do downstream (resource servers, permissions, and roles that shape the permissions claim).