SenseCrypt Docs
Get StartedAdd Login quickstarts

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/callback in development.
  • Node.js 18+.

SenseCrypt specifics to know

  • Issuer: https://<your-tenant>.<domain>. Resolve endpoints from {issuer}/.well-known/openid-configuration.
  • PKCE S256 is required even for confidential clients — SenseCrypt verifies the challenge if you send one, and requires it for clients configured with require_pkce. Always send it.
  • response_type=code only.
  • id_token is ES256, validated against the tenant jwks_uri. sub is a stable, per-tenant pseudonymous user id.
  • Token endpoint auth: confidential clients use client_secret_post (also client_secret_basic, client_secret_jwt, private_key_jwt — see token_endpoint_auth_methods_supported in discovery). This guide uses client_secret_post.

Install dependencies

npm install express express-session openid-client
yarn add express express-session openid-client
pnpm add express express-session openid-client

openid-client handles discovery, PKCE, the code exchange, and ES256 id_token validation.

Uncertain (library specifics): the code below targets 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

.env
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-string

Discover the issuer once

sensecrypt.js
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

app.js
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
  1. Open http://localhost:3000/login.
  2. Complete the email + QR + face proof on your phone.
  3. You are redirected to /me.

Verify it workedGET /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_grant at the token step. Common causes: the code was already used (single-use; watch for double-fired callbacks), the code_verifier didn't survive to the callback, or the redirect_uri differs 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. Wrong client_id/client_secret, or the app isn't a confidential client. Confidential clients must send the secret; SPA clients must not.
  • redirect_uri mismatch. Must match the allow-list byte-for-byte.
  • id_token validation errors. Confirm SENSECRYPT_ISSUER equals the iss in the token; openid-client derives the JWKS from discovery.
  • access_denied on the callback URL. The user's email isn't permitted for this app — an authorization decision by the group access gate.

Next steps

On this page