SenseCrypt Docs
Concepts

OIDC & OAuth 2.0

SenseCrypt as an OpenID Connect issuer — the Authorization Code flow with PKCE, discovery, PAR, client authentication, grant types, and the token, userinfo, introspection, revocation, and logout endpoints.

SenseCrypt is a standard OpenID Connect provider built on OAuth 2.0. If your app already speaks OIDC, integration is conventional: redirect to authorize, exchange the code, validate the ID token. The biometric ceremony happens between the redirect and the callback and is invisible to your code (see How SenseCrypt works).

Each tenant is its own issuer at its own hostname (see Multi-tenancy). Always resolve endpoints from that tenant's discovery document rather than hard-coding paths.

Discovery

Every tenant publishes an OpenID discovery document and a JWKS:

GET {issuer}/.well-known/openid-configuration
GET {issuer}/.well-known/jwks.json

Read authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, and the other advertised metadata from the discovery document. The shape of the document follows the tenant's security profile: a Standard issuer advertises the full surface below; a FAPI 2.0/CIBA issuer (a profile chosen when the tenant is created, or earned by a Standard tenant the moment every one of its applications adopts the profile — and lost again when one does not) advertises the narrower FAPI shape called out after the sample. The sample below is an abridged Standard issuer's document — the real one carries more keys (endpoint URLs are all under the tenant's own issuer host):

{
  "issuer": "https://acme.example.com",
  "authorization_endpoint": "https://acme.example.com/v1/idp/oidc/authorize",
  "token_endpoint": "https://acme.example.com/v1/idp/oidc/token",
  "userinfo_endpoint": "https://acme.example.com/v1/idp/oidc/userinfo",
  "jwks_uri": "https://acme.example.com/.well-known/jwks.json",
  "pushed_authorization_request_endpoint": "https://acme.example.com/v1/idp/oidc/par",
  "require_pushed_authorization_requests": false,
  "revocation_endpoint": "https://acme.example.com/v1/idp/oidc/revoke",
  "introspection_endpoint": "https://acme.example.com/v1/idp/oidc/introspect",
  "end_session_endpoint": "https://acme.example.com/v1/idp/oidc/logout",
  "backchannel_authentication_endpoint": "https://acme.example.com/v1/idp/oidc/bc-authorize",
  "response_types_supported": ["code", "id_token", "id_token token", "code id_token", "code token", "code id_token token"],
  "response_modes_supported": ["query", "fragment", "form_post", "jwt", "query.jwt", "fragment.jwt", "form_post.jwt"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials", "urn:openid:params:grant-type:ciba", "implicit"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["ES256", "RS256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "client_secret_jwt", "private_key_jwt"],
  "code_challenge_methods_supported": ["S256"],
  "backchannel_token_delivery_modes_supported": ["poll", "ping", "push"],
  "claims_parameter_supported": true,
  "authorization_response_iss_parameter_supported": true,
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "amr", "acr", "sid", "updated_at", "..."]
}

A few things worth knowing about the document:

  • The sample above is abridged. The keys it leaves out, all of which a live Standard document carries: dpop_signing_alg_values_supported, request_parameter_supported (false), request_uri_parameter_supported (true), request_object_signing_alg_values_supported, authorization_signing_alg_values_supported, token_endpoint_auth_signing_alg_values_supported, revocation_endpoint_auth_methods_supported, introspection_endpoint_auth_methods_supported, prompt_values_supported, acr_values_supported, display_values_supported, backchannel_logout_supported, backchannel_logout_session_supported, backchannel_authentication_request_signing_alg_values_supported, and backchannel_user_code_parameter_supported (false — SenseCrypt is passwordless, so there is no per-user code to verify). Read the live document; do not treat this sample as the key list.
  • scopes_supported and claims_supported are dynamic — they reflect that tenant's enabled scopes and attribute schema. Disable an attribute in the console and it disappears from claims_supported; add a custom scope and it appears in scopes_supported. Read them from the live document; don't assume a fixed list. The protocol claims are always there, sid and updated_at included.
  • The JWKS lists the tenant's current signing key plus any key still inside a rotation grace window, so select the verification key by the token's kid and tolerate more than one key.
  • token_endpoint_auth_methods_supported lists the four methods above on a Standard issuer. Where discovery publishes mtls_endpoint_aliases — a deployment that runs the mutual TLS listener — the list also carries the two RFC 8705 methods, tls_client_auth and self_signed_tls_client_auth, alongside tls_client_certificate_bound_access_tokens: true; the aliases are where a client presents its certificate, and they cover the token, revocation, introspection, PAR, backchannel-authentication and userinfo endpoints (never the browser-facing /authorize). mtls_endpoint_aliases absent from the live document means the mutual TLS methods and certificate-bound tokens are not available on that deployment. The same auth-method list is advertised for the revocation and introspection endpoints.
  • require_pushed_authorization_requests is false on a Standard issuer — PAR is available but optional (see below). On a FAPI 2.0/CIBA issuer it is true: every authorization request must be pushed, and the document also narrows response_types_supported to ["code"], response_modes_supported to query plus the signed JARM forms (jwt, query.jwt), token_endpoint_auth_methods_supported to ["private_key_jwt"] (plus the two mutual TLS methods when the listener is configured), and the signing-algorithm sets to the FAPI 2.0 §5.4 subset it can actually offer — token_endpoint_auth_signing_alg_values_supported and request_object_signing_alg_values_supported become ["ES256", "PS256"], while id_token_signing_alg_values_supported and authorization_signing_alg_values_supported become ["ES256"] (the issuer signs tokens with ES256 or RS256, and §5.4 rules RS256 out, so ES256 is all that is left). RS256 and HS256 are never offered. Configure your client from the live document, not from this sample.
  • backchannel_token_delivery_modes_supported is ["poll", "ping", "push"] on a Standard issuer and ["poll", "ping"] on a FAPI 2.0/CIBA issuer — the FAPI-CIBA profile does not allow push delivery (see CIBA).

The Authorization Code flow with PKCE

SenseCrypt supports the Authorization Code flow and requires PKCE with S256:

  1. Your app generates a PKCE code_verifier and its S256 code_challenge, plus a random state, and redirects the browser to the authorization_endpoint.
  2. The user completes the face ceremony on their phone.
  3. The browser returns to your redirect_uri with ?code=...&state=....
  4. Your app exchanges the code (with the code_verifier) at the token_endpoint for tokens.

Here is the full round-trip, including the on-device face ceremony that sits between the redirect (step 2) and the callback (step 3):

redirect to authorization_endpointresponse_type=code, client_id, redirect_uri,scope, state, code_challenge, code_challenge_method=S256 GET /v1/idp/oidc/authorize 302 to enter-email page submit email render QR page (universal link baked into the QR) scan the on-screen QR, then complete the face scan device /complete (records authenticated_at + authorized user) redirect to redirect_uri?code=...&state=... deliver code + state POST /v1/idp/oidc/tokengrant_type=authorization_code, code, code_verifier,client auth if confidential 200 access_token + id_token (app's registered alg) + expires_in + scope + refresh_token? GET /.well-known/jwks.json tenant public keys (select by kid) validate client + redirect_uri,scope + audience gates, enforce S256 PKCE face proof on device —no biometric data leaves the phone consume single-use code, verify S256(code_verifier),group access gate (max_age was already decided at /authorize) verify id_token signature + claims User Your app (RP) Browser SenseCrypt (tenant issuer) SenseCrypt Authenticator (phone)

A minimal authorize redirect:

GET {authorization_endpoint}
  ?response_type=code
  &client_id={client_id}
  &redirect_uri={your_redirect_uri}
  &scope=openid profile email
  &state={state}
  &code_challenge={code_challenge}
  &code_challenge_method=S256

Key rules — these are enforced, not advisory:

  • PKCE is S256-only. plain is explicitly rejected. A challenge sent with no method is treated as S256. Public/SPA clients are required to use PKCE. The verifier must be 43–128 characters of the RFC 7636 unreserved set (A–Z a–z 0–9 - . _ ~); a verifier presented when the session stored no challenge is a strict reject.
  • response_type=code is the default and the recommended flow. The implicit response types — id_token and id_token token — and the hybrid response types — code id_token, code token and code id_token token — are available for relying parties that need them, but they are registerable per application and off by default: a request for a type your application is not registered for is refused with unsupported_response_type. When you do use them, the front-channel material (an id_token, an access token, or both) is returned straight off the authorization endpoint in the URL fragment (or, with response_mode=form_post, as an auto-submitted form POST — response_mode=query is refused); a nonce is required whenever an id_token is returned that way (every one of these types except code token); no refresh token is issued off the authorization endpoint; and the id_token binds what travelled beside it — at_hash over the access token and, for the hybrid types, c_hash over the code (see Validate a token). A hybrid code is still redeemed at the token endpoint, with PKCE, exactly like the code flow. A front-channel access token belongs to no refresh-token grant, so revoking your application's refresh token does not end it: it stays valid until it expires unless it is revoked at the revocation endpoint, the user signs out of your application, the user is suspended, deleted or renamed, the tenant is deleted, or — for a hybrid type — its code is redeemed a second time (see Validate a token for what each of those does and does not reach) — which is why the code flow stays the recommendation. An application on the FAPI 2.0 profile is code-only.
  • redirect_uri is validated against your application's registered allow-list (exact match: scheme and host are case-insensitive, but the path is byte-exact, so /callback and /callback/ are different URIs). A mismatch is refused.
  • The authorization code is single-use, short-lived, and bound to your client_id and redirect_uri. It is also bound to the requesting tenant/client before it is consumed, so a cross-tenant or cross-client exchange is refused without burning the code. For an application on the FAPI 2.0/CIBA profile the lifetime is capped at 60 seconds (FAPI 2.0 §5.3.2.1), whatever the deployment default is — redeem promptly.

The step-by-step version with copy-pasteable requests is in the Add Login (OIDC) quickstart. The authorize endpoint is documented in the API reference.

The iss response parameter (RFC 9207)

Every authorization response carries an iss parameter naming the issuer that produced it — on success and on an error redirect alike, in whichever place the response mode puts the parameters (query, fragment, or the form POST body), and as a claim inside the signed response JWT when JARM is used. Discovery advertises this as authorization_response_iss_parameter_supported: true.

GET {your_redirect_uri}?code=...&state=...&iss=https%3A%2F%2Facme.example.com

If your application federates more than one issuer, compare iss with the issuer you started the request at and refuse the callback if they differ — that is what the parameter is for (RFC 9207, the OAuth mix-up defence). A single-issuer application can simply ignore it.

Requesting individual claims (the claims parameter)

SenseCrypt honors the OIDC Core §5.5 claims request parameter at /authorize and at PAR (claims_parameter_supported: true). It takes JSON with an id_token and/or a userinfo member naming the claims you want:

{ "id_token": { "email": null }, "userinfo": { "name": { "essential": true } } }
  • Each member may name at most 50 claims, and the whole parameter is capped at 8 KiB. Malformed JSON, a non-object member, or an over-long value is invalid_request.
  • essential, value and values are accepted but not enforced — SenseCrypt parses them and releases the claim if it can, rather than failing the request.
  • It reshapes a release; it never widens one. A requested claim is released only when the attribute behind it belongs to a scope your application may request. Asking for a claim outside that set simply does not produce it.
  • The request is remembered for the life of the grant: the userinfo member is re-applied when you call UserInfo with the resulting access token, and both members survive a refresh.

The per-application switch id_token_includes_scope_claims decides whether scope-released profile claims ride in the id_token at all. It defaults to on. Turn it off and those claims are available at UserInfo only — with the exception of response_type=id_token, where there is no access token to fetch them with.

Forcing re-authentication with max_age

Alongside login_hint, SenseCrypt honors the OIDC max_age parameter at /authorize (and on a PAR push). It is the maximum age, in seconds, of the user's authentication:

GET {authorization_endpoint}
  ?response_type=code
  &client_id={client_id}
  &redirect_uri={your_redirect_uri}
  &scope=openid profile email
  &state={state}
  &code_challenge={code_challenge}
  &code_challenge_method=S256
  &max_age=300

max_age is evaluated at the authorization endpoint, against the browser session's last face ceremony: if that ceremony is older than max_age seconds (or there is no session), SenseCrypt runs a fresh ceremony before it mints a code, so the code you redeem is fresh by construction. Nothing is re-checked at the token exchange. The resulting token's auth_time records when the ceremony happened — verify it yourself if the freshness matters to you. An application can also carry a default bound (default_max_age) that applies when a request sends no max_age.

max_age=0 therefore forces a face ceremony on every authorization. SenseCrypt also honours prompt at /authorizenone answers silently from the browser session or returns login_required, and login, consent and select_account each force a fresh ceremony (the advertised set is prompt_values_supported). display and ui_locales are accepted and ignored.

Client types and authentication

Your application registers as one of two client types, which determines how (and whether) it authenticates at the token endpoint:

  • Confidential clients (server-side apps) authenticate at the token endpoint. Supported methods are client_secret_post, client_secret_basic, client_secret_jwt, and private_key_jwt, plus — where discovery publishes mtls_endpoint_aliases — the RFC 8705 methods tls_client_auth (PKI: a registered subject DN and trust anchors) and self_signed_tls_client_auth (a registered certificate). An application on the FAPI 2.0/CIBA profile must use one of the asymmetric methods (private_key_jwt or a mutual TLS method); a FAPI 2.0/CIBA issuer advertises only those. Resolve the advertised set from discovery (token_endpoint_auth_methods_supported) and use the method your app is registered for. The method is strictly pinned: presenting a credential over a channel your app isn't registered for is rejected as invalid_client, with no silent downgrade. Client secrets are verified with Argon2id and support a rotation grace window so you can roll a secret without an outage.
  • Public / SPA clients have no secret (the database enforces this) and rely on PKCE. They must present no client credential at all — doing so is rejected. For SPA clients, PKCE and refresh-token rotation are always on.

Unknown client_id, wrong secret, and wrong method all surface as the same opaque invalid_client (401) — you cannot use the error to probe which clients exist.

Pushed Authorization Requests (PAR)

SenseCrypt supports RFC 9126 PAR. Your backend can pre-stage the authorization parameters with a POST to the PAR endpoint and receive a request_uri, then redirect the browser to /authorize with just client_id and request_uri:

POST {pushed_authorization_request_endpoint}
// → 201
{
  "request_uri": "urn:ietf:params:oauth:request_uri:<id>",
  "expires_in": 300,
  "authorize_url": "https://acme.example.com/v1/idp/oidc/authorize"
}

PAR is optional on a Standard issuer — the default integration is a direct /authorize redirect with a login_hint. It is mandatory for an application on the FAPI 2.0/CIBA profile (FAPI 2.0 §5.3.2.2): its discovery document advertises require_pushed_authorization_requests: true, and a direct /authorize from such an application is refused with invalid_request (redirected to the registered redirect_uri when there is one). Notes:

  • The pushed request_uri is single-use and short-lived (5 minutes by default), and is bound to the client_id that pushed it.
  • When PAR is used, per RFC 9126 the pushed parameters take precedence over any same-named query parameters on the /authorize redirect.
  • PAR is also where a signed request object (request=<JWS>) enters the browser flow: the authorization endpoint accepts none, and the only other endpoint that takes one is the CIBA backchannel endpoint. See Signed requests and responses.
  • Face references never travel through PAR — they are pre-staged server-side on the user record and fetched by the phone during the ceremony.

See the PAR API reference.

Endpoints at a glance

Resolve exact URLs from discovery; the paths below are the conventional shape.

PurposeEndpointAPI reference
DiscoveryGET /.well-known/openid-configurationref
JWKSGET /.well-known/jwks.jsonref
Pushed Authorization RequestPOST /v1/idp/oidc/parref
AuthorizationGET /v1/idp/oidc/authorizeref
TokenPOST /v1/idp/oidc/tokenref
UserInfoGET or POST /v1/idp/oidc/userinforef
Introspection (RFC 7662)POST /v1/idp/oidc/introspectref
Revocation (RFC 7009)POST /v1/idp/oidc/revokeref
RP-Initiated LogoutGET or POST /v1/idp/oidc/logoutref
Backchannel auth (CIBA)POST /v1/idp/oidc/bc-authorizeref

Grant types

The token endpoint accepts:

  • authorization_code — the interactive browser sign-in above.
  • refresh_token — refreshes an access token when your app requested the offline_access scope. See Tokens & sessions.
  • client_credentials — two different things share this grant. A machine-to-machine service account mints a Management-API token with it (see Machine-to-machine). A registered OIDC application can also mint a token for one of your resource servers with it, but only when Allow the client credentials grant (allow_client_credentials) is turned on for that application: it is off by default, and an authenticated application without it gets unauthorized_client (a bad credential still gets invalid_client). Such a token must be confidential, carries sub = its own client_id and gty: "client-credentials", and comes with no id_token and no refresh tokenopenid and offline_access on that grant are invalid_scope. Because no End-User is behind it, the UserInfo endpoint refuses it.
  • CIBA (urn:openid:params:grant-type:ciba) — decoupled authentication. See CIBA.

Any other grant_type is rejected with unsupported_grant_type. A successful token response is the same shape across the interactive grants:

{
  "access_token": "<jwt>",
  "id_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email",
  "refresh_token": "<opaque>"
}

id_token is present for the user grants; refresh_token appears only when offline_access was granted; expires_in is the access token's lifetime (not the ID token's).

UserInfo, introspection, revocation, logout

  • UserInfo — call GET or POST /userinfo to retrieve the scope-released claims for the user. The access token travels as Authorization: Bearer <access_token>, as Authorization: DPoP <access_token> for a DPoP-bound token, or — on POST — as the form-encoded access_token parameter (exactly one method per request). The scheme must match the binding: a DPoP-bound token presented as a bearer token is refused (RFC 9449 §7.2), and so is an unbound token presented with the DPoP scheme (§7.1); either way the 401 carries a DPoP challenge. The endpoint verifies the token against the tenant's JWKS, requires it to be an access token with an End-User behind it, and honors revocation. A token that is missing or does not verify is 401 with a WWW-Authenticate challenge; a malformed Authorization header, or a token sent by both methods at once, is 400 invalid_request with no challenge (see Error codes).
  • Introspection (RFC 7662) — confidential clients may introspect their own tokens. Anything unverifiable, expired, revoked, or belonging to another client returns {"active": false} with a 200 — never an error, so it is not an oracle.
  • Revocation (RFC 7009) — revokes a refresh token's whole family; if the presented token parses as one of your access-token JWTs, its jti is denylisted until it expires. Always returns an empty 200, found or not.
  • RP-Initiated Logout — ends the OP browser session behind the sc_op_session cookie, revokes the user's refresh-token families for every application that session signed in to, and queues a signed back-channel logout token for each of those that registered a backchannel_logout_uri. Send an id_token_hint: it is the only thing that proves which application is asking, and therefore the only way you get redirected to your post_logout_redirect_uri rather than shown a confirmation page. Without a hint SenseCrypt asks the user to confirm and then lands on its own signed-out page; a request that names a post_logout_redirect_uri with neither an id_token_hint nor a client_id is refused outright (400, nothing signed out). Any post_logout_redirect_uri must be on your application's dedicated allow-list (distinct from your /authorize redirect list). See Tokens & sessions.

On this page