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. 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, andbackchannel_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_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 protocol claims are always there,sidandupdated_atincluded.- 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. token_endpoint_auth_methods_supportedlists the four methods above on a Standard issuer. Where discovery publishesmtls_endpoint_aliases— a deployment that runs the mutual TLS listener — the list also carries the two RFC 8705 methods,tls_client_authandself_signed_tls_client_auth, alongsidetls_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_aliasesabsent 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_requestsisfalseon a Standard issuer — PAR is available but optional (see below). On a FAPI 2.0/CIBA issuer it istrue: every authorization request must be pushed, and the document also narrowsresponse_types_supportedto["code"],response_modes_supportedtoqueryplus the signed JARM forms (jwt,query.jwt),token_endpoint_auth_methods_supportedto["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_supportedandrequest_object_signing_alg_values_supportedbecome["ES256", "PS256"], whileid_token_signing_alg_values_supportedandauthorization_signing_alg_values_supportedbecome["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_supportedis["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:
- 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_type=codeis the default and the recommended flow. The implicit response types —id_tokenandid_token token— and the hybrid response types —code id_token,code tokenandcode 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 withunsupported_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, withresponse_mode=form_post, as an auto-submitted form POST —response_mode=queryis refused); anonceis required whenever an id_token is returned that way (every one of these types exceptcode token); no refresh token is issued off the authorization endpoint; and the id_token binds what travelled beside it —at_hashover the access token and, for the hybrid types,c_hashover 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_uriis validated against your application's registered allow-list (exact match: scheme and host are case-insensitive, but the path is byte-exact, so/callbackand/callback/are different URIs). 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. 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.comIf 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,valueandvaluesare 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
userinfomember 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=300max_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 /authorize — none 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, andprivate_key_jwt, plus — where discovery publishesmtls_endpoint_aliases— the RFC 8705 methodstls_client_auth(PKI: a registered subject DN and trust anchors) andself_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_jwtor 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 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 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_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. - 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.
| 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 or POST /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 or POST /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— 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 getsunauthorized_client(a bad credential still getsinvalid_client). Such a token must be confidential, carriessub= its ownclient_idandgty: "client-credentials", and comes with noid_tokenand no refresh token —openidandoffline_accesson that grant areinvalid_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
GETorPOST /userinfoto retrieve the scope-released claims for the user. The access token travels asAuthorization: Bearer <access_token>, asAuthorization: DPoP <access_token>for a DPoP-bound token, or — onPOST— as the form-encodedaccess_tokenparameter (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 theDPoPscheme (§7.1); either way the401carries aDPoPchallenge. 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 is401with aWWW-Authenticatechallenge; a malformedAuthorizationheader, or a token sent by both methods at once, is400 invalid_requestwith 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 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 — ends the OP browser session behind the
sc_op_sessioncookie, 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 abackchannel_logout_uri. Send anid_token_hint: it is the only thing that proves which application is asking, and therefore the only way you get redirected to yourpost_logout_redirect_urirather 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 apost_logout_redirect_uriwith neither anid_token_hintnor aclient_idis refused outright (400, nothing signed out). 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.
- Security profiles — Standard vs FAPI 2.0/CIBA, and how the discovery shape is derived.
- Sender-constrained tokens — DPoP and mutual TLS instead of bearer tokens.
- Signed requests and responses — JAR at PAR, and JARM.
- Back-channel logout — receiving logout tokens.
- Add Login (OIDC) — the hands-on quickstart.
Login methods
The three ways a user proves identity in a SenseCrypt sign-in — Simple QR in the Authenticator app, a FIDO2/WebAuthn passkey, and the enterprise Simple Webcam — and how to choose between them. Your OIDC/SAML integration is identical for all three.
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.