SenseCrypt Docs
Concepts

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:

  1. 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": "..."}.
  2. Select the key whose kid matches the token header. Tolerate multiple keys — during a rotation grace window the JWKS publishes more than one.
  3. 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 — your client_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 the max_age authorize parameter is checked against: request max_age to force a fresh re-authentication (step-up), and read auth_time to 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).
  • acrurn: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:

Requestaudazppermissions
No audience (default)your client_idabsentabsent
With an API audience, enforce_rbac = falsethe resource server's audienceyour client_idabsent (pure audience binding)
With an API audience, enforce_rbac = truethe resource server's audienceyour client_idthe 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"]
}
  • sid is 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_access scope 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_token to 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 confidential

Rotation 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:

On-device authenticator session (the phone) SenseCrypt IdP session (no SSO cookie) Relying-party session face proof on device — no biometric data leaves the phone id_token + access_token opaque refresh_token (offline_access only) re-runs access gate, recomputes permissions fresh Authenticator app on the phone Fresh face ceremony + device-bound key proof (pop) oauth_sessions row: pending → authorized → exchanged Access gate re-runs on every mint Mint id_token + access_token (auth_time pinned; amr = face, mfa, pop) Refresh-token family = sid (only durable server-side artifact; issued only with offline_access) RP session seeded from the validated id_token Call your API with the access token (valid until exp) grant_type=refresh_token → new access token

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 jti is denylisted until it expires. Always returns an empty 200. 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_hint is required — it identifies the subject to act on. Any post_logout_redirect_uri must be on your application's dedicated allow-list (separate from your /authorize redirect 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 kid and refetch on an unknown one.
  • acr is SenseCrypt-specific. urn:sensecrypt:face-aal3 is not a registered AAL value; don't map it to a standard assurance level.
  • auth_time doesn't move on refresh. If you need "how recently did the user physically authenticate", auth_time answers it — and it stays pinned to the original ceremony through every refresh.
  • offline_access is required for refresh. No offline_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 before exp.

On this page