A minimal Next.js app demonstrating how to authenticate users through Wacht using OpenID Connect, with all token material held server-side.
The browser never sees the OAuth access_token or refresh_token. It
holds nothing but an opaque session id in an httpOnly cookie. Every
external call goes through this app's own API routes, which means:
- You can swap Wacht out later by changing one env var. The UI doesn't care.
- All authentication traffic flows through code you control, so logs and metrics are yours.
- You can rewrite the
/authorizeparameters server-side (extra scopes, audience tweaks, customprompt) without touching the UI.
This is a standalone reference — it has no Wacht-specific SDK dependency.
Internally it uses openid-client,
which is a spec-compliant OIDC client that works with any OP.
| Path | What it does |
|---|---|
app/api/auth/login/route.ts |
Starts the OIDC flow: builds PKCE + state + nonce, redirects to Wacht's /oauth/authorize. |
app/api/auth/callback/route.ts |
Wacht 302s back here after consent. Exchanges the code for tokens, verifies the id_token (signature, issuer, audience, nonce), creates the BFF session. |
app/api/auth/me/route.ts |
Returns the signed-in user to the UI. Refreshes the access_token transparently when it's near expiry. |
app/api/auth/logout/route.ts |
Drops the local session and returns a URL the browser navigates to — Wacht's /oauth/logout with an id_token_hint so the logout cascade kills every dependent token. |
app/api/auth/post-logout/route.ts |
Wacht's post-logout landing — just sends the user home. |
lib/oidc.ts |
Lazy openid-client configuration (discovery + JWKS). |
lib/session.ts |
In-memory session store keyed by an opaque cookie. Swap for Redis / DB in production. |
app/page.tsx |
Landing page. Routes signed-in users to the dashboard. |
app/dashboard/page.tsx |
Server-rendered, gated on session. Shows the id_token claims and a sign-out button. |
In the Wacht console, under your OAuth app, create a client:
- Auth method:
none(public client) — recommended for browser/SPA flows. PKCE is mandatory and replaces the client secret. - Grant types:
authorization_code,refresh_token. - Redirect URIs:
http://localhost:3000/api/auth/callback(and any other origins you deploy to). - Post-logout redirect URIs:
http://localhost:3000/api/auth/post-logout.
Confidential clients work too — set WACHT_CLIENT_SECRET and the BFF
will pass it on every token call. PKCE remains active either way.
cp .env.example .env.local
# then edit:
WACHT_OAUTH_HOST=<your-oauth-fqdn>
WACHT_CLIENT_ID=<your-client-id>
WACHT_CLIENT_SECRET= # only if confidential
APP_URL=http://localhost:3000pnpm install
pnpm devOpen http://localhost:3000 and click Sign in with Wacht.
┌─────────┐ 1. /api/auth/login
│ Browser │ ─────────────────────────────┐
└────┬────┘ ▼
│ ┌────────────────┐
│ │ Next.js BFF │ ── PKCE / state / nonce
│ │ (this app) │ stored in memory
│ 302 to Wacht's └───────┬────────┘
│ /oauth/authorize │
│ ◀────────────────────────────────┘
▼
┌────────────┐
│ Wacht │ user signs in, approves consent
└─────┬──────┘
│ 302 back to /api/auth/callback?code=…
▼
┌─────────┐
│ Browser │
└────┬────┘ 3. /api/auth/callback?code=…
▼
┌────────────────┐ POST /oauth/token (code, code_verifier)
│ Next.js BFF │ ─────────────────────────────────────────┐
└────┬───────────┘ ▼
│ ┌────────────┐
│ access_token / refresh_token / id_token ◀────│ Wacht │
│ └────────────┘
│ store in memory, set httpOnly cookie
│ (session id only — no tokens leave the server)
│
▼ 302 /dashboard
┌─────────┐
│ Browser │ cookie: bff_session=<opaque>
└─────────┘
When the UI calls /api/auth/me, the BFF reads the session, transparently
refreshes the access_token if it's near expiry, then returns just the
identity claims. The browser still never sees a token.
When the user clicks Sign out, the BFF clears its own session and
returns a URL pointing at Wacht's /oauth/logout with the id_token_hint.
The browser navigates there; Wacht cascades the logout server-side
(revoking the access_token, the refresh token family, and the Wacht
session itself) and finally redirects to /api/auth/post-logout.
Anything you'd normally want to pass to the IdP — extra scopes, audience
indicators, custom prompt, hard-coded login_hint — happens in
app/api/auth/login/route.ts. The UI doesn't have to know about it.
For example, to silently retry the auth flow without showing consent (e.g. background token refresh):
const authorizeUrl = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: "none", // your call, not the UI's
});The defaults in this repo are deliberately the simplest thing that runs. For production, do at least these:
- Persist sessions outside the process. Swap the in-memory
Mapinlib/session.tsfor Redis / KV / Postgres. Otherwise a deploy or restart logs everyone out. - Encrypt token material at rest. If your session store is shared, wrap the tokens with an AEAD scheme keyed to a secret only the BFF knows.
- Bind the session cookie to a single origin. This repo uses
SameSite=Lax; if you're on a different domain than your API, setSameSite=None; Secure. - Handle refresh failures.
/api/auth/mealready drops the session and returns 401 on a failed refresh — make sure your UI catches that and routes the user back through/api/auth/login. - Don't log tokens. They're sensitive; log only the session id
and the user's
sub.
MIT.