Validate a SenseCrypt token
The canonical recipe for validating a SenseCrypt JWT — fetch the tenant JWKS from jwks_uri, select the key by kid, verify the signature with the algorithm your application registered (ES256 or RS256), and check iss, aud, exp, and token_use, with JWKS caching and key-rotation handling.
Every token SenseCrypt issues — the id_token and the access token — is a JWT signed with one of the tenant's own keys. An access token is always ES256; an id_token is signed with the algorithm your application is registered for — ES256 by default, or RS256 if you chose it. Anyone can hand you one of these tokens; validating it is what turns "a string the caller sent" into "a claim you can trust." This page is the canonical, end-to-end recipe. The quickstarts and other guides point here rather than repeating it.
If you use a mature OIDC or JWT library, it does all of this for you — you mostly just supply the issuer and your expected audience. The steps below are what a correct library does under the hood, so you can configure one properly or implement validation yourself when you have to.
Refresh tokens are not JWTs. They are opaque strings — do not try to parse or validate them. This page is about the
id_tokenand the access token only. See Tokens & sessions.
What you're validating
A decoded SenseCrypt JWT header looks like this:
{ "alg": "ES256", "kid": "<jwk-thumbprint>", "typ": "JWT" }algisES256orRS256— and you know which one before you write a line of code. An access token is alwaysES256(ECDSA on the NIST P-256 curve). Anid_tokenis signed with the algorithm your application is registered for:ES256by default, orRS256if you selected it under Applications → your app → Tokens → ID token signing algorithm in the console (id_token_signed_response_algover the Management API). That registration is what you get on every id_token for that application, on every flow — code, hybrid, CIBA and refresh alike. Pin that one value in your verifier and reject anything else — never trust the token's ownalgto tell you how to verify it.- Discovery tells you what the tenant offers, if you are writing one verifier for tenants you do not control:
id_token_signing_alg_values_supportedis["ES256", "RS256"]on a standard issuer and["ES256"]on a FAPI 2.0/CIBA issuer, which cannot register an RS256 application at all. Accept only the algorithms in that array — never an algorithm the document does not list. kidis the RFC 7638 thumbprint of the signing public key. You use it to pick the right key out of the JWKS.
The steps
-
Resolve the tenant's
jwks_urifrom discovery. Fetch{issuer}/.well-known/openid-configurationand readjwks_uri. Never hard-code the JWKS path — resolve it from discovery so it survives any future change. (The current value is{issuer}/.well-known/jwks.json, but discovery is the contract.) -
Fetch the JWKS.
GETthejwks_uri. It returns a JSON Web Key Set — the tenant's live public keys. An EC P-256 key ("kty": "EC","alg": "ES256") is always there; an RSA key ("kty": "RSA","alg": "RS256") is published as well once the tenant signs its first RS256 id_token. The RSA key is created on that first use, not when an application is registered for RS256, so a JWKS you cached before the first RS256 token will not contain it (the unknown-kidrule below covers this). Do not assume a key type — readktyandalgoff the key you selected bykid:{ "keys": [ { "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "use": "sig", "alg": "ES256", "kid": "…" } ] } -
Select the key by
kid. Match the token header'skidagainst thekidof each key in the set. Expect more than one key — during a signing-key rotation the JWKS publishes both the new and the outgoing key for an overlap window (see Handle key rotation). -
Verify the signature against the selected key, using the algorithm you pinned above (not the one in the token header).
-
Check the claims (below). A valid signature only proves SenseCrypt minted the token — the claims prove it was minted for you and is still current.
The claims to check
Check these on every token, after the signature verifies:
| Claim | Check |
|---|---|
iss | Must equal the tenant issuer you expect (the same issuer whose JWKS you verified against). This is your defence against a token from another tenant or another IdP. |
exp | Must be in the future. Allow only a small clock-skew leeway (a minute or two), not more. |
aud | Must contain the audience you expect (see below). |
iat | Present on every token; some libraries let you require it. |
nonce (id_token; required on the implicit flows) | If your request carried a nonce — and an implicit request (response_type=id_token or id_token token) always must — the id_token echoes it. Check it equals the value you generated for this sign-in and reject a mismatch or a missing claim: on a front-channel flow the nonce is what ties the id_token to your request and defeats replay. Not present when the request sent none (code flow without nonce, refresh-minted tokens). |
at_hash (id_token; id_token token and code id_token token only) | When an access token is returned beside the id_token from the authorization endpoint (response_type=id_token token or code id_token token), the id_token carries at_hash = base64url of the left-most 128 bits of the SHA-256 of the access_token string. Recompute it and reject a mismatch — it is the only thing binding the access token in the fragment to the id_token you validated. Absent on the code flow, on id_token alone, and on the id_token the token endpoint returns for a hybrid code. |
c_hash (id_token; code id_token and code id_token token only) | When an authorization code is returned beside the id_token from the authorization endpoint (the hybrid types), the id_token carries c_hash = base64url of the left-most 128 bits of the SHA-256 of the code string — the same construction as at_hash. Recompute it and reject a mismatch before redeeming the code: it is what lets you detect a substituted code. Absent on the code flow, on the implicit types, and on the id_token the token endpoint returns when you redeem the code. |
What aud should be depends on which token and how it was requested:
id_token—audis yourclient_id. An id_token authenticates the user to your app; checkaud == your_client_id.- Access token, no API audience requested —
audis also yourclient_id(the default binding). - Access token with an API audience (requested via
?audience=or?resource=) —audis the resource server's audience (e.g.https://api.your-app.example). An API validating an incoming access token must checkaudequals its own audience, and reject tokens minted for anything else. See Tokens & sessions for the full access-token shape.
Check token_use to tell the two apart
An access token carries "token_use": "access"; the id_token does not carry token_use at all. Use this to enforce the right token in the right place:
- Your API should require
token_use == "access"and reject an id_token presented as a bearer token. An id_token is proof of a login event, not an API credential — do not accept it at an API. - Do not treat an access token as proof of login. For "who is signed in," use the id_token (or call UserInfo with the access token).
subis stable, but not the database id. Once validated,subis a stable, per-tenant pseudonymous identifier — safe to use as your application's primary key for the user. It is not SenseCrypt's internal row id, and asubfrom one tenant is meaningless in another.
Cache the JWKS
Fetching the JWKS on every token check is unnecessary and slow. Cache it — the keys change only when a tenant rotates its signing key, which is rare. Most JWKS clients cache for you (for example, jwks_uri-aware clients in the common libraries). If you cache yourself, a short TTL (minutes to an hour) is fine, provided you also handle the unknown-kid case below so a rotation is never blocked by a stale cache.
Handle key rotation
Signing keys rotate. A rotated OIDC key starts signing immediately, and the outgoing key stays published in the JWKS for an overlap (grace) window, so tokens signed on either side of the switch verify. A verifier holding a cached JWKS will therefore meet the new kid before its cache refreshes, which is why the second rule matters. (SAML is different: it stages the new certificate in metadata before activating it.) Two rules make your verifier rotation-proof:
- Select by
kid, tolerate multiple keys. Never assume the JWKS has exactly one key, and never pin a single key. Pick the one whosekidmatches the token. - Re-fetch on an unknown
kid. If a token'skidis not in your cached JWKS, a rotation likely just happened — re-fetch the JWKS once and retry the lookup before rejecting the token. (Guard this so a flood of boguskids can't make you hammer the endpoint — for example, cap the re-fetch rate.) Most JWKS-caching clients do this automatically.
Operators can rotate a tenant's OIDC signing key on demand via the signing-key rotation endpoint; the outgoing key stays published through the grace window so in-flight tokens keep verifying.
Worked examples
These use the tenant's discovery document to find the JWKS, cache it, select by kid, and pin the algorithm your application registered. Both libraries also re-fetch on an unknown kid for you. ID_TOKEN_ALG below is "ES256" unless your application registered RS256; set it once from your own configuration, not from the token.
import jwt
from jwt import PyJWKClient
# disco = the discovery document, fetched once from
# {issuer}/.well-known/openid-configuration
# "ES256" (the default) or "RS256" — whatever this application registered.
ID_TOKEN_ALG = "ES256"
jwks_client = PyJWKClient(disco["jwks_uri"]) # caches + refetches on unknown kid
def validate(token: str, *, expected_aud: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token) # selects by kid
return jwt.decode(
token,
signing_key.key,
algorithms=[ID_TOKEN_ALG], # your app's registered alg — not the token's
issuer=disco["issuer"], # iss must be the tenant issuer
audience=expected_aud, # your client_id, or your API's audience
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)For an id_token from a front-channel (implicit or hybrid) response, also check nonce and — when an access token or a code came with it — at_hash / c_hash (same construction, over the code string):
import base64, hashlib
def validate_front_channel(id_token: str, *, client_id: str, expected_nonce: str,
access_token: str | None = None) -> dict:
claims = validate(id_token, expected_aud=client_id)
if claims.get("nonce") != expected_nonce: # ties the token to THIS request
raise ValueError("nonce mismatch")
if access_token is not None: # response_type=id_token token
digest = hashlib.sha256(access_token.encode("ascii")).digest()
at_hash = base64.urlsafe_b64encode(digest[:16]).rstrip(b"=").decode()
if claims.get("at_hash") != at_hash:
raise ValueError("at_hash mismatch")
return claimsFor an access token at an API, additionally assert token_use:
claims = validate(access_token, expected_aud="https://api.your-app.example")
if claims.get("token_use") != "access":
raise ValueError("expected an access token")import { createRemoteJWKSet, jwtVerify } from "jose";
// disco = the discovery document, fetched once from
// {issuer}/.well-known/openid-configuration
// "ES256" (the default) or "RS256" — whatever this application registered.
const ID_TOKEN_ALG = "ES256";
const JWKS = createRemoteJWKSet(new URL(disco.jwks_uri)); // caches + refetches on unknown kid
export async function validate(token: string, expectedAud: string) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: [ID_TOKEN_ALG], // your app's registered alg — not the token's
issuer: disco.issuer, // iss must be the tenant issuer
audience: expectedAud, // your client_id, or your API's audience
});
return payload;
}
// Front-channel (implicit) response: also check nonce and, for
// response_type=id_token token, at_hash.
import { createHash } from "node:crypto";
export async function validateFrontChannel(
idToken: string, clientId: string, expectedNonce: string, accessToken?: string,
) {
const payload = await validate(idToken, clientId);
if (payload.nonce !== expectedNonce) throw new Error("nonce mismatch");
if (accessToken !== undefined) {
const digest = createHash("sha256").update(accessToken, "ascii").digest();
const atHash = digest.subarray(0, 16).toString("base64url");
if (payload.at_hash !== atHash) throw new Error("at_hash mismatch");
}
return payload;
}Common mistakes
- Trusting the token's
alg. Pin the algorithm you know the token should carry —ES256for an access token, and for an id_token whichever alg the application registered. Accepting whateveralgthe header claims is the classic algorithm-confusion vulnerability; accepting the wrong pinned value just fails every token, so take it from your own configuration, not from the wire. - Pinning one JWKS key. Rotation publishes multiple keys; select by
kidand re-fetch on an unknown one. - Skipping
aud. A signature-valid token minted for a different client or API is still not for you. Always checkaud. - Accepting an id_token at an API. Require
token_use == "access"on API credentials; the id_token has notoken_use. - Verifying against the wrong issuer. The JWKS you verify against must belong to the token's
iss. In a multi-tenant deployment each tenant is its own issuer with its own keys — see Multi-tenancy. - Skipping
nonceon a front-channel id_token. On the implicit and hybrid flows the id_token arrives in the URL fragment; without checkingnonceagainst the value you generated, a captured id_token can be replayed into your app. Check it (andat_hashwhen an access token came with it,c_hashwhen a code did). - Treating a front-channel access token like a code-flow one. An access token returned straight off the authorization endpoint —
response_type=id_token token, or one of the hybrid typescode tokenandcode id_token token— is a plain bearer token with no refresh token behind it and no refresh-token family, so revoking a refresh token never reaches it. It stops working ahead of itsexpwhen: your application calls/revokewith the token itself; the user signs out of your application (RP-initiated logout ends the front-channel access tokens issued to your application for that user); the user is suspended or deleted (userinfo, introspection and the protected resource re-check the subject on every call); the user's email address is changed; the tenant, or the SenseCrypt account that owns it, is deleted; or — for a hybrid type — the code that travelled beside it is redeemed a second time, which revokes every token issued off that authorization (RFC 6749 §4.1.2). Every one of those takes effect where the token is presented back to SenseCrypt — userinfo, introspection and the protected resource; an API that only verifies the signature locally never learns of any of them and accepts the token untilexp, so introspect a front-channel token wherever that matters. Prefer the code flow whenever you can, and keep front-channel tokens short-lived and scoped narrowly.
Related
- Tokens & sessions — the full claim-by-claim shape of the id_token and access token.
- OIDC & OAuth 2.0 — discovery and the flows that mint these tokens.
- Multi-tenancy — per-tenant issuers, per-tenant keys, and the
kidselection rule. - JWKS endpoint — the endpoint reference.
CORS
How cross-origin browser access to SenseCrypt works — the backend registers no CORS middleware of its own, so CORS is terminated at the operator's reverse proxy, and confidential apps should prefer server-side token exchange.
Glossary
Definitions of the terms used across the SenseCrypt documentation — protocol, authorization, key-custody, and device concepts, in the precise sense SenseCrypt uses them.