Skip to content
haunt/oauth

Next.js 16 · OAuth 2.1 · SSR

Login with Haunt, done right.

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

The parts that are easy to get wrong

Every card is a provider behaviour the template already accounts for, not a feature it merely enables.

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.

code_challenge_method=S256client_secret_basic

id_token verified, not decoded

Signature, issuer, audience and the nonce — checked against the live JWKS.

Rotation-safe refresh

Every refresh mints a new token and revokes the old. Replaying one wipes the session.

Backend-for-frontend

The provider sends no CORS headers, so the browser never talks to it directly.

Exact redirect_uri

Matched as a literal string. The template reads it from env, never from the request.

Claims resolved from both sources

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.

subpreferred_usernamepictureuidconnections

Errors that actually explain themselves

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.

invalid_grantinvalid_scopeinvalid_redirect

Tokens the browser never sees

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.

httpOnlyA256GCMsameSite=lax20 req/min budget

02The flow

Six steps, six files

Authorization code with PKCE, server-side throughout. Nothing here needs a client library.

  1. Step 01

    Mint the one-shot secrets

    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.ts
  2. Step 02

    Hand off to haunt.gg

    The 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.ts
  3. Step 03

    Verify before trusting the code

    State 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.ts
  4. Step 04

    Exchange, once

    Form-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.ts
  5. Step 05

    Check the id_token, then ask for the rest

    EdDSA 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.ts
  6. Step 06

    Seal the session

    Claims 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.ts

03Scopes

Five permissions, and no more

Request them explicitly. A scope your app is not registered for fails the authorize call before the user ever sees a consent screen.

openidRequired

Sign in. Without it there is no id_token and /userinfo refuses the token.

sub

identifyOpen

The basic profile, under the OIDC standard claim names.

name · preferred_username · picture · profile · uid

emailStaff-granted

The address plus its verification flag.

email · email_verified

connectionsStaff-granted

Third-party accounts the user linked on haunt.gg. Only on /userinfo, never in the id_token.

connections[]

offline_accessOpen

Refresh 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

Questions, answered

Can I do this from the browser instead?

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.

My login never reaches the callback. Where did it go?

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.

Why did one wrong secret cost me the whole login?

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.

Why does the user get logged out at random?

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.

Can I log the user out of haunt.gg too?

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.

Can I read the scopes out of the access token?

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.

Does localhost work for development?

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.

What are the rate limits?

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.

Try it against your own app.

Register an app on haunt.gg, drop the credentials into .env.local, and the flow above runs end to end.