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.jsonRead authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, and the other advertised metadata from the discovery document. A representative document looks like this (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"],
"grant_types_supported": ["authorization_code", "refresh_token", "urn:openid:params:grant-type:ciba"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"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"],
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "amr", "acr", "..."]
}A few things worth knowing about the document:
scopes_supportedandclaims_supportedare dynamic — they reflect that tenant's enabled scopes and attribute schema. Disable an attribute in the console and it disappears fromclaims_supported; add a custom scope and it appears inscopes_supported. Read them from the live document; don't assume a fixed list.- 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
kidand tolerate more than one key. require_pushed_authorization_requestsisfalse— PAR is available but optional (see below).
The Authorization Code flow with PKCE
SenseCrypt supports the Authorization Code flow and requires PKCE with S256:
- Your app generates a PKCE
code_verifierand itsS256code_challenge, plus a randomstate, and redirects the browser to theauthorization_endpoint. - The user completes the face ceremony on their phone.
- The browser returns to your
redirect_uriwith?code=...&state=.... - Your app exchanges the
code(with thecode_verifier) at thetoken_endpointfor 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):
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=S256Key rules — these are enforced, not advisory:
- PKCE is
S256-only.plainis explicitly rejected. A challenge sent with no method is treated asS256. 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_typeiscode. SenseCrypt implements the code flow only — there is no implicit or hybrid flow.redirect_uriis validated against your application's registered allow-list (exact match, with trailing-slash tolerance). A mismatch is refused.- The authorization
codeis single-use, short-lived, and bound to yourclient_idandredirect_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.
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.
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=300Because SenseCrypt has no server-side SSO cookie, every interactive sign-in is already a fresh on-device face ceremony — so max_age is how you demand a re-authentication (step-up) for a sensitive action rather than accepting a recent session. The value is round-tripped through the ceremony and checked at the token exchange: if the completed face ceremony is older than max_age seconds, the exchange fails with login_required (error codes) and you restart the authorize flow. The resulting token's auth_time records when that ceremony happened.
max_age=0 therefore demands a face ceremony completed essentially at exchange time. Only login_hint and max_age influence the authorize request in this way — SenseCrypt does not read prompt, display, or ui_locales.
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, andprivate_key_jwt— 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 asinvalid_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 — the default integration is a direct /authorize redirect with a login_hint. Notes:
- The pushed
request_uriis single-use and short-lived (5 minutes by default), and is bound to theclient_idthat pushed it. - When PAR is used, per RFC 9126 the pushed parameters take precedence over any same-named query parameters on the
/authorizeredirect. - 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.
| Purpose | Endpoint | API reference |
|---|---|---|
| Discovery | GET /.well-known/openid-configuration | ref |
| JWKS | GET /.well-known/jwks.json | ref |
| Pushed Authorization Request | POST /v1/idp/oidc/par | ref |
| Authorization | GET /v1/idp/oidc/authorize | ref |
| Token | POST /v1/idp/oidc/token | ref |
| UserInfo | GET /v1/idp/oidc/userinfo | ref |
| Introspection (RFC 7662) | POST /v1/idp/oidc/introspect | ref |
| Revocation (RFC 7009) | POST /v1/idp/oidc/revoke | ref |
| RP-Initiated Logout | GET /v1/idp/oidc/logout | ref |
| Backchannel auth (CIBA) | POST /v1/idp/oidc/bc-authorize | ref |
Grant types
The token endpoint accepts:
authorization_code— the interactive browser sign-in above.refresh_token— refreshes an access token when your app requested theoffline_accessscope. See Tokens & sessions.client_credentials— machine-to-machine access for the Management API. See Machine-to-machine.- 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 /userinfowithAuthorization: Bearer <access_token>to retrieve the scope-released claims for the user. The endpoint verifies the token against the tenant's JWKS, requires it to be an access token, and honors revocation. - Introspection (RFC 7662) — confidential clients may introspect their own tokens. Anything unverifiable, expired, revoked, or belonging to another client returns
{"active": false}with a200— 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
jtiis denylisted until it expires. Always returns an empty200, found or not. - RP-Initiated Logout — SenseCrypt has no server-side SSO cookie (each sign-in is a fresh face ceremony), so logout revokes the refresh tokens for the subject. Because there is no session to clear, an
id_token_hintis required (a missing hint isinvalid_request, not a silent no-op), and anypost_logout_redirect_urimust be on your application's dedicated allow-list (distinct from your/authorizeredirect list). See Tokens & sessions.
Related
- Tokens & sessions — token contents,
sub, scopes, audiences, and refresh behavior. - Authorization — who is allowed to sign in, and the
permissionsclaim. - CIBA — backchannel, no-browser authentication.
- Add Login (OIDC) — the hands-on quickstart.
How SenseCrypt works
The passwordless, on-device biometric model — a face proof and hardware-bound key on the phone, a biometric-blind SaaS, per-sign-in rotation, and no passwords anywhere.
SAML 2.0
SenseCrypt as a SAML 2.0 identity provider — metadata, bindings, NameID, request signatures, assertion signing and encryption, attribute release, and how the biometric ceremony fits behind SP-initiated SSO.