SenseCrypt Docs
Concepts

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:

  1. 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.
  2. Select the key whose kid matches 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.
  3. 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 registeredES256 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 — 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.
  • sid — the id of the OP browser session behind the sign-in. It is emitted only to an application that registered a backchannel_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 no sid at all. Do not confuse it with the access token's sid, 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:

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. This is not the ID token's sid (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.
  • cnf appears when the token is sender-constrained: cnf.jkt for a token bound to a DPoP key, cnf["x5t#S256"] for one bound to a client certificate. A bound token comes back with token_type: "DPoP" rather than "Bearer" when the constraint is DPoP, and a resource server must refuse a cnf-carrying token presented as a plain bearer token. Introspection reports both cnf and token_type. Refresh keeps the constraint: the new token carries the cnf proved 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_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 (sc_op_session browser session + token artifacts) 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) Authorization: pending → authorized → exchanged Access gate re-runs on every mint Mint id_token + access_token (auth_time pinned; amr = face, mfa, pop) ACCESS token sid = the refresh-token family (issued only with offline_access)id_token sid = the OP browser session (back-channel-logout apps only) 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 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 jti is denylisted until it expires. Always returns an empty 200. 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 an id_token_hint — it proves which application is asking, and it is what makes a post_logout_redirect_uri redirect possible at all; without one the user is asked to confirm and lands on SenseCrypt's signed-out page. 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. 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-revoked initiator 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 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