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.
Add Sign in with SenseCrypt to a Node.js Express app as a confidential client — the server holds the client_secret, runs the Authorization Code + PKCE exchange, and validates the ES256 id_token. Tokens never reach the browser. The biometric face ceremony happens on the user's phone, between the redirect and the callback.
Prerequisites
- A SenseCrypt tenant — its own OIDC issuer at
https://<your-tenant>.<domain>. - An OIDC application registered as Confidential (Applications → New OIDC application → Confidential), which issues a
client_secret. - Your redirect URI on the app's allow-list, e.g.
http://localhost:3000/callbackin development. - Node.js 18+.
SenseCrypt specifics to know
- Issuer:
https://<your-tenant>.<domain>. Resolve endpoints from{issuer}/.well-known/openid-configuration. - PKCE
S256is required even for confidential clients — SenseCrypt verifies the challenge if you send one, and requires it for clients configured withrequire_pkce. Always send it. response_type=codeonly.- id_token is ES256, validated against the tenant
jwks_uri.subis a stable, per-tenant pseudonymous user id. - Token endpoint auth: confidential clients use
client_secret_post(alsoclient_secret_basic,client_secret_jwt,private_key_jwt— seetoken_endpoint_auth_methods_supportedin discovery). This guide usesclient_secret_post.
Install dependencies
npm install express express-session openid-clientyarn add express express-session openid-clientpnpm add express express-session openid-clientopenid-client handles discovery, PKCE, the code exchange, and ES256 id_token validation.
openid-client v6 (functional API). v5 is class-based (Issuer.discover(...), client.callback(...)). If you're on v5 the flow is identical but the calls differ — check the package README for your installed version. The SenseCrypt parameters are the same regardless.Configure environment
SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
SENSECRYPT_CLIENT_ID=your_client_id
SENSECRYPT_CLIENT_SECRET=your_client_secret
SENSECRYPT_REDIRECT_URI=http://localhost:3000/callback
SESSION_SECRET=generate-a-long-random-stringDiscover the issuer once
import * as client from "openid-client";
let configPromise;
export function getConfig() {
// Cache the discovery result for the process lifetime.
configPromise ??= client.discovery(
new URL(process.env.SENSECRYPT_ISSUER),
process.env.SENSECRYPT_CLIENT_ID,
process.env.SENSECRYPT_CLIENT_SECRET, // confidential client
);
return configPromise;
}The login and callback routes
import express from "express";
import session from "express-session";
import * as client from "openid-client";
import { getConfig } from "./sensecrypt.js";
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" },
}),
);
// Start sign-in: build the authorize URL with PKCE (S256) + state.
app.get("/login", async (req, res) => {
const config = await getConfig();
const code_verifier = client.randomPKCECodeVerifier();
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
const state = client.randomState();
// Persist for the callback (server-side session).
req.session.pkce = { code_verifier, state };
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.SENSECRYPT_REDIRECT_URI,
scope: "openid profile email",
code_challenge,
code_challenge_method: "S256", // required by SenseCrypt
state,
});
res.redirect(authUrl.href);
});
// Callback: exchange code + validate the ES256 id_token in one call.
app.get("/callback", async (req, res) => {
const config = await getConfig();
const saved = req.session.pkce;
if (!saved) return res.status(400).send("Missing PKCE state");
const currentUrl = new URL(req.originalUrl, process.env.SENSECRYPT_REDIRECT_URI);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: saved.code_verifier,
expectedState: saved.state,
});
const claims = tokens.claims(); // validated: iss, aud, exp checked
delete req.session.pkce;
// Establish YOUR session keyed on the stable sub.
req.session.user = { sub: claims.sub, email: claims.email, name: claims.name };
res.redirect("/me");
});
app.get("/me", (req, res) => {
if (!req.session.user) return res.redirect("/login");
res.json(req.session.user);
});
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});
app.listen(3000, () => console.log("http://localhost:3000/login"));The raw token exchange (confidential client)
curl -X POST {token_endpoint} \
-d grant_type=authorization_code \
-d code={code} \
-d redirect_uri=http://localhost:3000/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"
}Add offline_access to the scope in step 4 to also receive a refresh_token.
Run and verify
node --env-file=.env app.js- Open
http://localhost:3000/login. - Complete the email + QR + face proof on your phone.
- You are redirected to
/me.
Verify it worked — GET /me returns the validated sub, email, and name. Sign in again: the same sub confirms a validated, stable identity.
Optional: call userinfo
You can fetch the scope-released claims with the access token:
curl {userinfo_endpoint} -H "Authorization: Bearer {access_token}"Troubleshooting
invalid_grantat the token step. Common causes: thecodewas already used (single-use; watch for double-fired callbacks), thecode_verifierdidn't survive to the callback, or theredirect_uridiffers from the one sent to/authorize. It is also what you get if the user was removed from the app's required group between authorize and exchange — the access gate re-runs at token time and fails closed.invalid_client. Wrongclient_id/client_secret, or the app isn't a confidential client. Confidential clients must send the secret; SPA clients must not.redirect_urimismatch. Must match the allow-list byte-for-byte.- id_token validation errors. Confirm
SENSECRYPT_ISSUERequals theissin the token;openid-clientderives the JWKS from discovery. access_deniedon the callback URL. The user's email isn't permitted for this app — an authorization decision by the group access gate.
Next steps
- Add Login (OIDC) — the protocol-level walkthrough.
- Tokens & sessions — refresh tokens (
offline_access), rotation, and revocation. - Machine-to-machine — if this service also needs to call the Management API with no user present.
React SPA (PKCE)
Add Sign in with SenseCrypt to a browser-only React single-page app using the OIDC Authorization Code flow with PKCE (no client secret) and an ES256 id_token.
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.