PKCE, and it is not optional
haunt.gg requires a code_challenge even from confidential clients, and only accepts S256. The template sends the verifier and the client secret — the token endpoint wants both.
Next.js 16 · OAuth 2.1 · SSR
A server-rendered starting point with the whole flow already wired: PKCE, a verified id_token, an encrypted session cookie and refresh-token rotation that survives a second browser tab.
No client library · No database · Secrets never leave the server
01What's wired
Every card is a provider behaviour the template already accounts for, not a feature it merely enables.
haunt.gg requires a code_challenge even from confidential clients, and only accepts S256. The template sends the verifier and the client secret — the token endpoint wants both.
Signature, issuer, audience and the nonce — checked against the live JWKS.
Every refresh mints a new token and revokes the old. Replaying one wipes the session.
The provider sends no CORS headers, so the browser never talks to it directly.
Matched as a literal string. The template reads it from env, never from the request.
The id_token carries identity; connections come from /userinfo, which is where the bulky claims live. The dashboard shows exactly what each granted scope returned.
The provider ships two incompatible error shapes and answers invalid_grant with a 401. Both are normalized into one typed error, and the failure page names the code it got.
Session and tokens live in an httpOnly cookie encrypted with A256GCM — signed alone would hand a 30-day refresh token to anyone who reads it. Nothing crosses into a client component.
02The flow
Authorization code with PKCE, server-side throughout. Nothing here needs a client library.
A PKCE verifier, a state and a nonce are generated and sealed into a short-lived encrypted cookie. All three are checked on the way back.
GET /api/auth/loginapp/api/auth/login/route.tsThe authorize URL carries the challenge, the literal registered redirect_uri and an explicit scope list. Omitting scope would silently grant everything the app is registered for.
→ /api/auth/oauth2/authorizelib/haunt/oauth.tsState must match the cookie, and iss must match the configured issuer — RFC 9207's guard against a provider mix-up. Only then is the code exchanged.
GET /api/auth/callback?code&state&issapp/api/auth/callback/route.tsForm-encoded, Basic auth, redirect_uri repeated verbatim. The provider consumes the code before it checks the secret, so a wrong secret burns the code — there is no second attempt.
POST /api/auth/oauth2/tokenlib/haunt/oauth.tsEdDSA signature against the live JWKS, plus issuer, audience and nonce. The bulky claims are not in the token — connections come from /userinfo.
GET /api/auth/oauth2/userinfolib/haunt/oauth.tsClaims and tokens go into an httpOnly cookie encrypted with A256GCM. Encrypted rather than signed, because the payload holds a 30-day refresh token.
Set-Cookie: haunt_sessionlib/session.ts03Scopes
Request them explicitly. A scope your app is not registered for fails the authorize call before the user ever sees a consent screen.
openidRequiredSign in. Without it there is no id_token and /userinfo refuses the token.
sub
identifyOpenThe basic profile, under the OIDC standard claim names.
name · preferred_username · picture · profile · uid
emailStaff-grantedThe address plus its verification flag.
email · email_verified
connectionsStaff-grantedThird-party accounts the user linked on haunt.gg. Only on /userinfo, never in the id_token.
connections[]
offline_accessOpenRefresh tokens — staying signed in past the first hour. The wire name is fixed by the protocol; the consent screen calls it “Refresh session”.
refresh_token
The consent screen supports partial grants, so the user may hand over less than was asked for. Read the granted list back from the token response rather than assuming it — tokens.scope is the authority.
04FAQ
No. The provider sends no CORS headers on the token, userinfo or JWKS endpoints, so a fetch from a page is blocked before it starts. That is why this template is a backend-for-frontend: the browser only ever talks to this app, and the client secret never leaves the server. It is also the safer arrangement — a public SPA has nowhere to keep a secret anyway.
Look at the browser's address bar. Errors the provider detects before it has validated your redirect_uri — invalid_client, invalid_redirect, client_disabled, unsupported_response_type — redirect to haunt.gg/login?error=… instead of your callback, so your handler is never invoked. Nine times out of ten it is a redirect_uri that differs from the registered one by a trailing slash or a port.
The token endpoint consumes the authorization code before it validates the client secret. A failed exchange therefore burns the code, and retrying answers invalid_grant. Fix the secret and start again from /api/auth/login.
Almost certainly a replayed refresh token. Every refresh rotates: the response carries a new token and the old one is marked revoked. Presenting a revoked token is read as theft, and the provider deletes every token for that app-and-user pair. Persist the new value on every refresh, and never let two requests refresh the same token concurrently — app/api/auth/refresh/route.ts collapses those into one call.
Not through this flow. The RP-initiated logout endpoint requires a client flag that no haunt.gg app can be created with, and it needs a sid claim the id_token consequently never carries. Signing out means revoking your tokens and dropping your cookie — which is the behaviour you want regardless: leaving one app should not end someone's haunt.gg session.
No — it is a 32-character opaque string, not a JWT. Decoding it gets you nothing. The granted scopes come back as the scope field of the token response.
Yes, but register the exact spelling you browse to. The provider ignores the port only for the IP literal 127.0.0.1 (and ::1), never for the name localhost. If you registered http://localhost:3000/api/auth/callback, that is the string you must send — same host, same port, same path.
Per source IP, per minute: 20 on the token endpoint, 30 on authorize, 60 on userinfo. A server-side integration behind a single egress IP shares that budget across all of its users, so back off on 429 rather than retrying immediately.
Register an app on haunt.gg, drop the credentials into .env.local, and the flow above runs end to end.