SenseCrypt Docs
Get StartedAdd Login quickstarts

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/callback in 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 S256 is required. plain is rejected. Public/SPA clients rely on PKCE alone; confidential clients should still send a challenge.
  • response_type=code only.
  • id_token is ES256, validated against the tenant jwks_uri. sub is a stable, per-tenant pseudonymous user id — safe as a primary key.
  • Token endpoint auth: client_secret_post (this guide), plus client_secret_basic, client_secret_jwt, private_key_jwt — see token_endpoint_auth_methods_supported in discovery.

Install dependencies

pip install "fastapi[standard]" authlib itsdangerous httpx

authlib 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

main.py
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",
    },
)
.env
SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
SENSECRYPT_CLIENT_ID=your_client_id
SENSECRYPT_CLIENT_SECRET=your_client_secret
SESSION_SECRET=generate-a-long-random-string

The 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.

main.py (continued)
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
  1. Open http://localhost:8000/login.
  2. Complete the email + QR + face proof on your phone.
  3. You are redirected to /me.

Verify it workedGET /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's SessionMiddleware must be installed and the session cookie must survive the round-trip. Authlib keeps state/PKCE in the session.
  • invalid_grant at the token step. The code is single-use (watch for double-fired callbacks); the redirect_uri must 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 / aud errors. Confirm SENSECRYPT_ISSUER matches the token's iss. Authlib and PyJWKClient both fetch the JWKS from discovery.
  • access_denied on the callback. The user's email isn't permitted for this app by the group access gate.

Next steps

On this page