Tokens & sessions
ID tokens, access tokens, and refresh tokens issued by SenseCrypt — per-tenant signing keys, 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: access tokens with ES256, and each application's id_tokens with the algorithm it registered — ES256 by default or RS256 (see Multi-tenancy and the validation recipe). Verify signatures against the tenant's jwks_uri, selecting the key by kid.
Verifying any token
Access tokens and ID tokens carry a header of the form:
{ "alg": "ES256", "kid": "<jwk-thumbprint>", "typ": "JWT" }(alg is RS256 on an ID token for an application that registered it. Two other JWTs on the wire carry an explicit type so they can never be accepted where an ID token is expected — a back-channel logout token is typ: "logout+jwt" and a client's DPoP proof is typ: "dpop+jwt" — but typ is not by itself the test: a JARM response JWT is a plain typ: "JWT", checked against its own iss/aud/exp instead.)
The kid is the RFC 7638 thumbprint of the signing public key. To verify:
- Fetch the tenant's JWKS from
jwks_uri. An EC P-256 key is always there —{"kty": "EC", "crv": "P-256", "x": "...", "y": "...", "use": "sig", "alg": "ES256", "kid": "..."}— joined by an RSA key once the tenant signs its first RS256 ID token. - Select the key whose
kidmatches the token header. Tolerate multiple keys — during a rotation grace window, and whenever the tenant signs with both algorithms, the JWKS publishes more than one. - Verify the signature with the algorithm you expect that token to carry (see below), 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 a JWT returned from the authorization_code exchange, signed with the algorithm your application registered — ES256 by default, or RS256 if you chose it (id_token_signed_response_alg). Validate its signature against that algorithm 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. sid— the id of the OP browser session behind the sign-in. It is emitted only to an application that registered abackchannel_logout_uri, because its only use is correlating a later logout token with the session that ended; every other application receives an ID token with nosidat all. Do not confuse it with the access token'ssid, which names the refresh-token family and is a different value with a different meaning.
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. This is not the ID token'ssid(that one names the OP browser session, and only back-channel-logout applications receive it).token_use: "access"distinguishes access tokens from ID tokens on the wire.cnfappears when the token is sender-constrained:cnf.jktfor a token bound to a DPoP key,cnf["x5t#S256"]for one bound to a client certificate. A bound token comes back withtoken_type: "DPoP"rather than"Bearer"when the constraint is DPoP, and a resource server must refuse acnf-carrying token presented as a plain bearer token. Introspection reports bothcnfandtoken_type. Refresh keeps the constraint: the new token carries thecnfproved on the refresh call.
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 keeps an OP browser session in an HttpOnly, host-only, SameSite=Lax cookie (sc_op_session) established by the face ceremony. While it is live, a second application on the same tenant can be authorized without another ceremony, and prompt=none answers silently. Its id reaches an application as the id_token's sid only when that application registered a backchannel_logout_uri. The lifetime is absolute from the last ceremony — 8 hours by default, configurable per tenant, and 0 disables browser sessions entirely so every authorization runs a fresh ceremony and prompt=none always answers login_required. "Logging out" therefore means ending that session and 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 ends the browser session and revokes the user's refresh-token families for every application that session signed in to, then fans a signed back-channel logout token out to each of those that registered a
backchannel_logout_uri. Send anid_token_hint— it proves which application is asking, and it is what makes apost_logout_redirect_uriredirect possible at all; without one the user is asked to confirm and lands on SenseCrypt's signed-out page. 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. Lifecycle events revoke eagerly rather than leaving access to TTL expiry, but they do not all cut the same planes:
- Suspending, deleting or renaming a user (console or SCIM) severs the identity plane as well as the sessions: device keys, passkeys, refresh-token families and browser sessions all go.
- Removing a user from a group is an authorization change, so it stays on the session planes: the device key and passkey survive, because the user may still reach other applications through other groups, and the access gate is what closes the application they lost. It revokes the refresh-token families for the applications they can no longer reach, and each of those applications that opted into the
access-revokedinitiator is sent a back-channel logout token for every live browser session of theirs that had signed in to it — a user with no live session has nothing to announce. A user left in no app-attached group loses every remaining refresh-token family in the tenant and their OP browser session as well, with every application in that session's sign-in list that opted in told the same way.
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).