SenseCrypt Docs
Get Started

Add Login (OIDC)

Add biometric single sign-on to your web app with the OIDC Authorization Code flow and PKCE — discovery, redirect, code exchange, and ES256 id_token validation.

This quickstart adds Sign in with SenseCrypt to a web application using the OIDC Authorization Code flow with PKCE. Users authenticate with an on-device face scan on their phone — no passwords, and no biometric data ever reaches your servers or ours.

SenseCrypt is a standard OpenID Connect provider, so if your stack already speaks OIDC this is a conventional integration: redirect to authorize, exchange the code, validate the ID token. The face ceremony happens between the redirect and the callback and is invisible to your code.

Using a framework? Jump to a copy-paste quickstart for Next.js, React SPA, Node/Express, or Python/FastAPI. This page is the protocol-level walkthrough they all build on.

Prerequisites

  • A SenseCrypt tenant. Each tenant is its own OIDC issuer at https://<your-tenant>.<domain>.
  • An OIDC application registered in the admin console, with your app's redirect URI allow-listed.
  • Your client_id (plus a client_secret for confidential apps; SPAs use PKCE only).

Register the application first: in the admin console, go to Applications → New OIDC application and set a name, your redirect URI(s), and the client type — Confidential for a server-side app (issues a client_secret) or SPA for a browser/native app (PKCE only, no secret).

Creating an OIDC application in the SenseCrypt admin console

The Applications → New OIDC application form: name, redirect URIs, and client type.

Confidential vs SPA at a glance

ConfidentialSPA / public
Has a client_secretYesNo
Token endpoint authclient_secret_post (or _basic, _jwt, private_key_jwt)none — PKCE only
PKCERequired (send S256)Required (S256)
Sends client_secret at /tokenYesNever (a blank secret is rejected)
Refresh-token rotationConfigurableAlways on

Discover the endpoints

Don't hard-code endpoints — resolve them from the tenant's discovery document:

curl https://<your-tenant>.<domain>/.well-known/openid-configuration

A trimmed response looks like this (endpoints are the ones your app will call):

{
  "issuer": "https://<your-tenant>.<domain>",
  "authorization_endpoint": "https://<your-tenant>.<domain>/v1/idp/oidc/authorize",
  "token_endpoint": "https://<your-tenant>.<domain>/v1/idp/oidc/token",
  "userinfo_endpoint": "https://<your-tenant>.<domain>/v1/idp/oidc/userinfo",
  "jwks_uri": "https://<your-tenant>.<domain>/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "urn:openid:params:grant-type:ciba"],
  "id_token_signing_alg_values_supported": ["ES256"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "client_secret_jwt", "private_key_jwt"],
  "scopes_supported": ["openid", "profile", "email", "offline_access"]
}

Use the advertised authorization_endpoint, token_endpoint, userinfo_endpoint, and jwks_uri. Note that response_types_supported is ["code"] (code flow only) and code_challenge_methods_supported is ["S256"] (PKCE is mandatory and S256-only).

Redirect the user to authorize

The sequence below traces the full ceremony from your app's side — your code writes only two of its seven steps, the redirect (Step 1) and the code exchange (Step 7), while Steps 2–6 run on SenseCrypt's hosted pages and the user's phone.

redirect to authorization_endpoint (response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, S256) GET /v1/idp/oidc/authorize validate client_id + redirect_uri (allow-list), scope/audience gate, enforce PKCE S256 302 to the hosted email-entry page user enters email on the hosted page render a QR code to scan send signed face proof; ceremony completes authorize the session, record auth_time, issue single-use code 302 back to redirect_uri?code=...&state=... deliver code + state to your callback check for error vs code, verify state matches POST /v1/idp/oidc/token (code, code_verifier, client_id, client_secret if confidential) consume code (single-use, bound to client_id + redirect_uri), verify PKCE, re-run access gate, mint ES256 tokens id_token + access_token (+ refresh_token if offline_access) validate id_token (ES256; iss, aud, exp, nonce) Steps 2–6 run on SenseCrypt's hosted pages and the phone — no app code scan the QR, then face proof on device — no biometric data leaves the phone Step 7 (your code): exchange the code Your app (RP) User's browser SenseCrypt IdP Phone (SenseCrypt Authenticator)

Generate a PKCE code_verifier and code_challenge (S256), a random state (CSRF protection), and optionally a nonce, then redirect the browser:

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

Common parameters:

  • scopeopenid is required. Add profile and email for those claims, and offline_access to receive a refresh_token. You can only request scopes the application is allow-listed for.
  • state — an opaque random value you check on the callback to prevent CSRF.
  • nonce — optional; if you send one it is echoed in the id_token so you can bind the token to this request.
  • login_hint — optional email to pre-fill SenseCrypt's email-entry page. SenseCrypt renders the same page whether or not the email is known, so a hint never leaks whether an account exists.

The user approves with a face scan on their phone; the browser returns to your redirect_uri with ?code=...&state=....

SenseCrypt requires PKCE with S256 (plain is rejected). The code is single-use and bound to your client_id + redirect_uri, which is validated against your allow-list by exact match (with trailing-slash tolerance).

If sign-in fails, SenseCrypt redirects back with an error instead of a code:

{your_redirect_uri}?error=access_denied&error_description=<reason>&state={state}

error=access_denied means the ceremony was declined or the user isn't permitted for this app (see Troubleshooting). Always check for error before looking for code, and verify state matches what you sent.

Exchange the code for tokens

Send the code and code_verifier to the token endpoint. A confidential app also sends its client_secret; a SPA omits the secret and authenticates with PKCE alone.

curl -X POST {token_endpoint} \
  -d grant_type=authorization_code \
  -d code={code} \
  -d redirect_uri={your_redirect_uri} \
  -d client_id={client_id} \
  -d code_verifier={code_verifier}
  # confidential clients also add: -d client_secret={client_secret}

Successful response:

{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6…",
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email"
}

You get back an id_token (an ES256 JWT), an access_token, and — if you requested the offline_access scope — a refresh_token.

Token-endpoint errors use standard OAuth codes, for example:

{ "error": "invalid_grant", "error_description": "…" }

invalid_grant is deliberately opaque — it covers an expired/used code, a failed PKCE check, and an authorization block (the group access gate re-runs at exchange time). See Troubleshooting.

Validate the ID token

Verify the id_token before you trust it. Fetch the tenant's JWKS from the jwks_uri, select the key by the token's kid, and verify the ES256 signature. Then check the claims:

  • iss equals the tenant issuer (the issuer from discovery).
  • aud equals your client_id.
  • exp is in the future (and iat is sane).
  • nonce matches the one you sent (if you sent one).

Notable claims in the id_token:

{
  "iss": "https://<your-tenant>.<domain>",
  "sub": "8f2c…stable-per-tenant-id",
  "aud": "{client_id}",
  "exp": 1735689600,
  "iat": 1735686000,
  "auth_time": 1735686000,
  "nonce": "…",
  "email": "jane@acme.com",
  "name": "Jane Doe",
  "amr": ["face", "mfa", "pop"],
  "acr": "urn:sensecrypt:face-aal3"
}

The sub claim is a stable, per-tenant pseudonymous user id — safe to use as your primary key (it is not the internal database row id). amr/acr describe the original face ceremony (acr is a SenseCrypt-defined value, not a standards-registered one) and do not advance on refresh. Most OIDC libraries do steps 3 and 4 for you — see the framework quickstarts.

Call userinfo (optional)

Retrieve the scope-released claims for the signed-in user with the access token:

curl {userinfo_endpoint} -H "Authorization: Bearer {access_token}"
{ "sub": "8f2c…", "name": "Jane Doe", "email": "jane@acme.com", "email_verified": true }

Verify it worked

  1. Trigger sign-in and complete the email → QR → face proof on your phone.
  2. Confirm your callback received a code (not an error) and that state matched.
  3. Confirm the token exchange returned an id_token, and that its signature and iss/aud/exp validate.
  4. Sign in a second time — the sub should be identical. A stable sub confirms the token validated and the identity is consistent, which is what you key your user records on.

Troubleshooting

  • redirect_uri mismatch / rejected at /authorize. The redirect_uri must be on the application's allow-list and match byte-for-byte (scheme, host, port, path). Add every environment's callback URL.
  • PKCE errors / invalid_grant. SenseCrypt is S256-only — never send code_challenge_method=plain. The same code_verifier from step 2 must reach step 3. Don't send a code_verifier if you didn't send a code_challenge (SenseCrypt rejects a stray verifier).
  • code already used. The authorization code is single-use. Some browsers speculatively prefetch the callback URL, firing the exchange twice; guard your callback so only the first request exchanges the code.
  • invalid_client. Wrong client_id/client_secret, or the wrong client type. Confidential clients must send the secret; SPA clients must send no secret (a blank one is rejected).
  • id_token signature or aud failures. Verify against the jwks_uri from discovery and check the token's kid (the JWKS may list a current key plus one in a rotation grace window). Make sure the issuer you validate against equals the token's iss.
  • error=access_denied on the callback. Either the user declined/failed the ceremony on their phone (error_description carries the reason, e.g. a face mismatch or timeout), or their email isn't permitted for this application by the group access gate. The latter is an authorization decision, not a bug.

Next steps

On this page