SenseCrypt Docs
Reference

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_token and 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" }
  • alg is ES256 or RS256 — and you know which one before you write a line of code. An access token is always ES256 (ECDSA on the NIST P-256 curve). An id_token is signed with the algorithm your application is registered for: ES256 by default, or RS256 if you selected it under Applications → your app → Tokens → ID token signing algorithm in the console (id_token_signed_response_alg over 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 own alg to 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_supported is ["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.
  • kid is the RFC 7638 thumbprint of the signing public key. You use it to pick the right key out of the JWKS.

The steps

  1. Resolve the tenant's jwks_uri from discovery. Fetch {issuer}/.well-known/openid-configuration and read jwks_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.)

  2. Fetch the JWKS. GET the jwks_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-kid rule below covers this). Do not assume a key type — read kty and alg off the key you selected by kid:

    {
      "keys": [
        {
          "kty": "EC",
          "crv": "P-256",
          "x": "…",
          "y": "…",
          "use": "sig",
          "alg": "ES256",
          "kid": "…"
        }
      ]
    }
  3. Select the key by kid. Match the token header's kid against the kid of 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).

  4. Verify the signature against the selected key, using the algorithm you pinned above (not the one in the token header).

  5. 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:

ClaimCheck
issMust 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.
expMust be in the future. Allow only a small clock-skew leeway (a minute or two), not more.
audMust contain the audience you expect (see below).
iatPresent 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_tokenaud is your client_id. An id_token authenticates the user to your app; check aud == your_client_id.
  • Access token, no API audience requestedaud is also your client_id (the default binding).
  • Access token with an API audience (requested via ?audience= or ?resource=) — aud is the resource server's audience (e.g. https://api.your-app.example). An API validating an incoming access token must check aud equals 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).

sub is stable, but not the database id. Once validated, sub is 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 a sub from 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:

  1. Select by kid, tolerate multiple keys. Never assume the JWKS has exactly one key, and never pin a single key. Pick the one whose kid matches the token.
  2. Re-fetch on an unknown kid. If a token's kid is 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 bogus kids 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.

Python (PyJWT)
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 claims

For 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")
Node / TypeScript (jose)
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 — ES256 for an access token, and for an id_token whichever alg the application registered. Accepting whatever alg the 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 kid and 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 check aud.
  • Accepting an id_token at an API. Require token_use == "access" on API credentials; the id_token has no token_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 nonce on a front-channel id_token. On the implicit and hybrid flows the id_token arrives in the URL fragment; without checking nonce against the value you generated, a captured id_token can be replayed into your app. Check it (and at_hash when an access token came with it, c_hash when 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 types code token and code 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 its exp when: your application calls /revoke with 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 until exp, 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.

On this page