SenseCrypt Docs
Get StartedAdd Login quickstarts

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. plain is rejected. Public/SPA clients rely on PKCE for client authentication.
  • response_type is code. SenseCrypt implements the Authorization Code flow only (no implicit/hybrid).
  • The id_token is an ES256 JWT. Verify it against the tenant's jwks_uri (advertised in discovery). The sub claim 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-client
yarn add openid-client
pnpm add openid-client
Uncertain (library specifics): openid-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

.env.local
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-bytes

Create a small helper that discovers the issuer once and caches the config:

lib/sensecrypt.ts
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.

app/api/auth/login/route.ts
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:

app/page.tsx
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.

app/api/auth/callback/route.ts
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
  1. Open http://localhost:3000 and click Sign in with SenseCrypt.
  2. Complete the email + QR + face ceremony on your phone.
  3. You should land on /dashboard with a session.

Verify it worked — add a tiny debug route (remove before shipping) to confirm the validated claims:

app/api/auth/me/route.ts
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_uri mismatch / invalid_request at authorize. The SENSECRYPT_REDIRECT_URI must be byte-for-byte on the application's allow-list. http vs https, port, and path all matter.
  • PKCE errors / invalid_grant at the token step. SenseCrypt requires S256; make sure you did not send code_challenge_method=plain, and that the same code_verifier from the login cookie reaches the callback. The code is single-use — a double-fired callback (some browsers prefetch) will fail the second time.
  • id_token signature / aud failures. Confirm SENSECRYPT_ISSUER exactly matches the tenant issuer (the iss in the token). openid-client fetches the JWKS from discovery, so a wrong issuer is the usual cause.
  • access_denied returned 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_SECRET unset.

Next steps

On this page