Python (FastAPI + Authlib)
Add Sign in with SenseCrypt to a Python FastAPI app using Authlib, the OIDC Authorization Code flow with PKCE, and ES256 id_token validation against the tenant JWKS.
Add Sign in with SenseCrypt to a Python FastAPI app using Authlib, which handles discovery, PKCE, the code exchange, and ES256 id_token validation for you. This is a confidential-client integration: the server holds the client_secret. The face ceremony happens on the user's phone, between the redirect and the callback.
Prefer Flask? Authlib's OAuth registry works the same way with authlib.integrations.flask_client. The SenseCrypt-side parameters are identical. This guide uses FastAPI + Starlette sessions.
Prerequisites
- A SenseCrypt tenant — its own OIDC issuer at
https://<your-tenant>.<domain>. - An OIDC application registered as Confidential (Applications → New OIDC application → Confidential).
- Your redirect URI on the app's allow-list, e.g.
http://localhost:8000/callbackin development. - Python 3.9+.
SenseCrypt specifics to know
- Issuer:
https://<your-tenant>.<domain>. Resolve endpoints from{issuer}/.well-known/openid-configuration; don't hard-code paths. - PKCE
S256is required.plainis rejected. Public/SPA clients rely on PKCE alone; confidential clients should still send a challenge. response_type=codeonly.- id_token is ES256, validated against the tenant
jwks_uri.subis a stable, per-tenant pseudonymous user id — safe as a primary key. - Token endpoint auth:
client_secret_post(this guide), plusclient_secret_basic,client_secret_jwt,private_key_jwt— seetoken_endpoint_auth_methods_supportedin discovery.
Install dependencies
pip install "fastapi[standard]" authlib itsdangerous httpxauthlib provides the OIDC client; itsdangerous backs Starlette's signed session cookie; httpx is Authlib's async HTTP backend.
Uncertain (library specifics): Authlib validates the id_token against the JWKS from the discovery document automatically when you register with server_metadata_url. Confirm your Authlib version enforces PKCE — pass code_challenge_method="S256" in the client kwargs (shown below) to be explicit, since SenseCrypt requires it.
Register the client
import os
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
ISSUER = os.environ["SENSECRYPT_ISSUER"] # https://<tenant>.<domain>
oauth = OAuth()
oauth.register(
name="sensecrypt",
client_id=os.environ["SENSECRYPT_CLIENT_ID"],
client_secret=os.environ["SENSECRYPT_CLIENT_SECRET"],
# Discovery — Authlib reads authorization/token/jwks endpoints from here.
server_metadata_url=f"{ISSUER}/.well-known/openid-configuration",
client_kwargs={
"scope": "openid profile email", # add "offline_access" for a refresh_token
"code_challenge_method": "S256", # SenseCrypt requires S256 PKCE
"token_endpoint_auth_method": "client_secret_post",
},
)SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
SENSECRYPT_CLIENT_ID=your_client_id
SENSECRYPT_CLIENT_SECRET=your_client_secret
SESSION_SECRET=generate-a-long-random-stringThe login and callback routes
Authlib stores the PKCE code_verifier, state, and nonce in the session for you, and validates the returned id_token on the callback.
REDIRECT_URI = "http://localhost:8000/callback"
@app.get("/login")
async def login(request: Request):
# Generates state + PKCE (S256) and redirects to the authorize endpoint.
return await oauth.sensecrypt.authorize_redirect(request, REDIRECT_URI)
@app.get("/callback")
async def callback(request: Request):
# Exchanges the code, validates the ES256 id_token (iss, aud, exp, nonce),
# and returns the parsed claims in token["userinfo"].
token = await oauth.sensecrypt.authorize_access_token(request)
claims = token["userinfo"] # sub, email, name, …
request.session["user"] = {"sub": claims["sub"], "email": claims.get("email")}
return RedirectResponse(url="/me")
@app.get("/me")
async def me(request: Request):
user = request.session.get("user")
if not user:
return RedirectResponse(url="/login")
return JSONResponse(user)
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/")The raw token exchange
For reference, Authlib performs this exchange (confidential client → client_secret_post):
curl -X POST {token_endpoint} \
-d grant_type=authorization_code \
-d code={code} \
-d redirect_uri=http://localhost:8000/callback \
-d client_id={client_id} \
-d client_secret={client_secret} \
-d code_verifier={code_verifier}{
"access_token": "eyJ…",
"id_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}Run and verify
fastapi dev main.py # serves on http://localhost:8000- Open
http://localhost:8000/login. - Complete the email + QR + face proof on your phone.
- You are redirected to
/me.
Verify it worked — GET /me returns your validated sub and email. Sign in again: the same sub confirms a validated, stable identity.
Alternative: validate the id_token yourself
If you don't want a full OIDC client, you can drive the flow with httpx and verify the id_token with PyJWT against the tenant's JWKS — this is exactly what the demo relying party in the SenseCrypt repo does:
import jwt
from jwt import PyJWKClient
# disco = the discovery document; fetched once from
# {issuer}/.well-known/openid-configuration
jwks = PyJWKClient(disco["jwks_uri"])
signing_key = jwks.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["ES256"], # SenseCrypt signs id_tokens with ES256
audience=SENSECRYPT_CLIENT_ID, # aud must be your client_id
issuer=disco["issuer"], # iss must be the tenant issuer
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)For a confidential client, add client_secret to the token POST; for a SPA/public client, omit it and rely on the code_verifier. SenseCrypt rejects a present-but-blank client_secret on SPA clients, and rejects a stray code_verifier when the original /authorize carried no challenge — so send exactly what your client type requires.
Troubleshooting
mismatching_state/ missing state. Starlette'sSessionMiddlewaremust be installed and the session cookie must survive the round-trip. Authlib keepsstate/PKCE in the session.invalid_grantat the token step. Thecodeis single-use (watch for double-fired callbacks); theredirect_urimust match the one sent to/authorize; and the access gate re-runs at token time — a user removed from the app's group fails closed here.invalid_client. Wrong credentials or wrong client type. Confidential clients send the secret; SPA clients must not.- id_token signature /
auderrors. ConfirmSENSECRYPT_ISSUERmatches the token'siss. Authlib andPyJWKClientboth fetch the JWKS from discovery. access_deniedon the callback. The user's email isn't permitted for this app by the group access gate.
Next steps
- Add Login (OIDC) — the protocol-level walkthrough.
- Relying-party demo — the full FastAPI reference implementation this guide is based on.
- CIBA backchannel — decoupled, no-browser sign-in from a Python backend.
Node.js (Express)
Add Sign in with SenseCrypt to a Node.js Express app as a confidential OIDC client using openid-client, the Authorization Code flow with PKCE, and ES256 id_token validation.
Concepts
The mental model behind SenseCrypt — passwordless biometric sign-in, the standard protocols it speaks, multi-tenant issuers, tokens, sessions, and authorization.