Next.js (App Router)
Add Sign in with SenseCrypt to a Next.js App Router app using the OIDC Authorization Code flow with PKCE and an ES256 id_token verified against the tenant JWKS.
Add Sign in with SenseCrypt to a Next.js App Router application. SenseCrypt is a standard OpenID Connect provider, so this is a conventional Authorization Code + PKCE integration — the biometric face ceremony happens on the user's phone, between the redirect and the callback, and is invisible to your code.
This guide runs the OAuth exchange server-side in Route Handlers so the client_secret (if your app is confidential) and the tokens never touch the browser. That is the recommended shape for a Next.js app that has a server.
Prerequisites
- A SenseCrypt tenant. Each tenant is its own OIDC issuer at
https://<your-tenant>.<domain>. - An OIDC application registered in the admin console (Applications → New OIDC application) with your redirect URI allow-listed. Register it as Confidential (a server-rendered Next.js app can keep a secret) — you may also use a SPA/public client with PKCE only.
- Node.js 18+ and a Next.js 14/15 project using the App Router.
Your redirect URI for this guide is http://localhost:3000/api/auth/callback in development. Add that exact value to the application's allow-list. redirect_uri is matched exactly (with trailing-slash tolerance).
SenseCrypt specifics to know
- Issuer:
https://<your-tenant>.<domain>— per tenant. Everything is resolved from its discovery document at{issuer}/.well-known/openid-configuration; don't hard-code endpoint paths. - PKCE is required and
S256-only.plainis rejected. Public/SPA clients rely on PKCE for client authentication. response_typeiscode. SenseCrypt implements the Authorization Code flow only (no implicit/hybrid).- The
id_tokenis an ES256 JWT. Verify it against the tenant'sjwks_uri(advertised in discovery). Thesubclaim is a stable, per-tenant pseudonymous user id — safe to use as your primary key.
Install dependencies
We use openid-client — a certified, well-maintained OIDC client that handles discovery, PKCE, the code exchange, and ES256 id_token validation for you.
npm install openid-clientyarn add openid-clientpnpm add openid-clientopenid-client v6 changed its public API substantially from v5 (v6 is a functional API; v5 was class-based). The snippets below target v6. If you are pinned to v5, the concepts are identical but the calls differ — see that package's README for your version. The SenseCrypt-side parameters (S256, scope, redirect_uri) are the same either way.Configure environment
SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
SENSECRYPT_CLIENT_ID=your_client_id
# Only for a Confidential client. Omit for a SPA/public client.
SENSECRYPT_CLIENT_SECRET=your_client_secret
SENSECRYPT_REDIRECT_URI=http://localhost:3000/api/auth/callback
# Any long random string — signs the short-lived PKCE/state cookie.
AUTH_COOKIE_SECRET=please-generate-32-plus-random-bytesCreate a small helper that discovers the issuer once and caches the config:
import * as client from "openid-client";
let cached: client.Configuration | undefined;
export async function getConfig(): Promise<client.Configuration> {
if (cached) return cached;
// Discovery reads {issuer}/.well-known/openid-configuration and picks up
// authorization_endpoint, token_endpoint, jwks_uri, etc. automatically.
cached = await client.discovery(
new URL(process.env.SENSECRYPT_ISSUER!),
process.env.SENSECRYPT_CLIENT_ID!,
process.env.SENSECRYPT_CLIENT_SECRET, // undefined for a SPA/public client
);
return cached;
}Start the sign-in (redirect to authorize)
Generate a PKCE code_verifier/code_challenge and a random state, stash the verifier + state in a short-lived cookie, then redirect to the authorization_endpoint.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import * as client from "openid-client";
import { getConfig } from "@/lib/sensecrypt";
export async function GET() {
const config = await getConfig();
const code_verifier = client.randomPKCECodeVerifier();
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
const state = client.randomState();
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.SENSECRYPT_REDIRECT_URI!,
scope: "openid profile email",
code_challenge,
code_challenge_method: "S256", // SenseCrypt requires S256
state,
});
// Persist the verifier + state for the callback. Use an encrypted/HTTP-only
// cookie; a signed session store works too.
const jar = await cookies();
jar.set("sc_pkce", JSON.stringify({ code_verifier, state }), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
path: "/",
});
redirect(authUrl.href);
}Wire a button or link to GET /api/auth/login:
export default function Home() {
return <a href="/api/auth/login">Sign in with SenseCrypt</a>;
}The browser lands on SenseCrypt's branded page, the user confirms their email and scans a QR with the Authenticator app, and after the on-device face proof SenseCrypt redirects back to your callback with ?code=...&state=....
Handle the callback (exchange + validate)
openid-client performs the code exchange and validates the ES256 id_token against the tenant JWKS (checking iss, aud, exp, and the state/PKCE binding) in one call.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import * as client from "openid-client";
import { getConfig } from "@/lib/sensecrypt";
export async function GET(request: Request) {
const config = await getConfig();
const jar = await cookies();
const raw = jar.get("sc_pkce")?.value;
if (!raw) return new Response("Missing PKCE state", { status: 400 });
const { code_verifier, state } = JSON.parse(raw);
// Exchanges the code and validates the id_token (signature, iss, aud, exp).
const tokens = await client.authorizationCodeGrant(
config,
new URL(request.url),
{ pkceCodeVerifier: code_verifier, expectedState: state },
);
const claims = tokens.claims()!; // sub, iss, aud, exp, plus profile/email
jar.delete("sc_pkce");
// Establish YOUR app session here (e.g. an encrypted session cookie keyed
// on claims.sub — the stable per-tenant user id). Storing raw tokens shown
// for illustration only.
jar.set("sc_session", JSON.stringify({ sub: claims.sub, email: claims.email }), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
redirect("/dashboard");
}Want to see the raw HTTP?
Under the hood, step 4 is the standard token exchange. If your app is confidential it sends client_secret alongside the code_verifier; a SPA/public app omits the secret and authenticates with PKCE alone:
curl -X POST {token_endpoint} \
-d grant_type=authorization_code \
-d code={code} \
-d redirect_uri={redirect_uri} \
-d client_id={client_id} \
-d code_verifier={code_verifier}
# confidential clients add: -d client_secret={client_secret}Response:
{
"access_token": "eyJ…",
"id_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}A refresh_token is included only if you added the offline_access scope in step 3.
Run and verify
npm run dev- Open
http://localhost:3000and click Sign in with SenseCrypt. - Complete the email + QR + face ceremony on your phone.
- You should land on
/dashboardwith a session.
Verify it worked — add a tiny debug route (remove before shipping) to confirm the validated claims:
import { cookies } from "next/headers";
export async function GET() {
const jar = await cookies();
const session = jar.get("sc_session")?.value;
return Response.json(session ? JSON.parse(session) : { anonymous: true });
}GET /api/auth/me should return your sub and email. A stable sub on repeat sign-ins confirms the id_token was validated and the identity is consistent.
Troubleshooting
redirect_urimismatch /invalid_requestat authorize. TheSENSECRYPT_REDIRECT_URImust be byte-for-byte on the application's allow-list.httpvshttps, port, and path all matter.- PKCE errors /
invalid_grantat the token step. SenseCrypt requiresS256; make sure you did not sendcode_challenge_method=plain, and that the samecode_verifierfrom the login cookie reaches the callback. Thecodeis single-use — a double-fired callback (some browsers prefetch) will fail the second time. - id_token signature /
audfailures. ConfirmSENSECRYPT_ISSUERexactly matches the tenant issuer (theissin the token).openid-clientfetches the JWKS from discovery, so a wrong issuer is the usual cause. access_deniedreturned to the callback. The user's email is not permitted for this application by the group access gate — this is an authorization decision, not a bug.- "client_secret" rejected on a SPA app. Public/SPA clients must not send a secret. Leave
SENSECRYPT_CLIENT_SECRETunset.
Next steps
- Add Login (OIDC) — the protocol-level walkthrough behind this guide.
- Tokens & sessions — the
subclaim, scopes, and refresh tokens (offline_access). - Authorization — the group access gate that decides who may sign in.
- React SPA + PKCE — if your frontend has no server.
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.
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.