Next.js quickstart
Register a client, install the SDK, mount four routes in a Next.js app and sign in once.
On this page
This page takes a Next.js application from nothing to one working sign-in. It uses the relying-party module of the identity SDK, which does discovery, PKCE, the pushed authorization request, client authentication and token checks for you.
Before you start#
| You need | Why |
|---|---|
| Node 22.12 or later | The SDK requires it. |
| The SDK | @tdacorp/identity-sdk, from the public npm registry. See the note below. |
| A registered client | Identity only signs people in to clients it knows. Step 1. |
Note
Install the SDK with pnpm add @tdacorp/identity-sdk. The examples import from that name. The package is published by TDACorp under the @tdacorp scope; if a copy of an older private tarball is pinned in your project, repack against 2.0.0 (the server now requires DPoP for clients that hold authorization codes).
1. Register a client#
Generate the key pair on your machine. You keep the private key and send only the public half, because identity authenticates your application with a signed assertion (private_key_jwt) and has no client secret to leak.
import { generateKeyPairSync } from 'node:crypto'// Run once on your machine. Ed25519 and P-256 are the two key types identity accepts.const { publicKey, privateKey } = generateKeyPairSync('ed25519')// The key id names this key in every client assertion you sign, and in the request to register it.export const kid = 'app-key-1'// Send this and the kid to whoever registers your client. It is public.export const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid }// Keep this in your secret manager. It never leaves your infrastructure.export const privateJwk = privateKey.export({ format: 'jwk' })An administrator registers the client for you. What to send, and the rules your redirect addresses must meet, are on Get a client registered. For this quickstart the values are:
| Value | Quickstart value |
|---|---|
| Type | web |
| Redirect URI | https://app.example.com/api/auth/callback |
| Scopes | openid, profile, email |
| Grants | authorization_code and refresh_token |
| Public key | The publicJwk from the script, with its kid |
You get back a client id that starts with tdaid_c_. A client that holds a code or refresh grant is registered with DPoP required by default, so the SDK needs a DPoP key too: see DPoP.
2. Configure the relying party#
Put the client id, the client key and a DPoP key in your secret store and read them from the environment. createRelyingParty reads discovery from the issuer on first use and keeps it for five minutes.
import { createRelyingParty, importDpopKeyPair } from '@tdacorp/identity-sdk/rp'// Secrets come from your secret store, never from source. The values are placeholders.const clientKey = JSON.parse(process.env.TDAID_CLIENT_KEY_JWK ?? '{}')const dpopKey = JSON.parse(process.env.TDAID_DPOP_KEY_JWK ?? '{}')export const rp = createRelyingParty({ issuer: 'https://identity.tdacorp.in', clientId: process.env.TDAID_CLIENT_ID ?? '', redirectUri: 'https://app.example.com/api/auth/callback', // The private half of the key whose public JWK was registered with the client. key: { kid: process.env.TDAID_CLIENT_KID ?? '', privateKey: clientKey }, // Required for any client registered with require_dpop. Keep it stable: refresh tokens are bound to it. dpop: await importDpopKeyPair(dpopKey),})redirectUri must equal the registered value exactly, and issuer must equal the issuer string identity publishes, byte for byte.
3. Mount the routes#
createNextAuthHandlers returns one GET and one POST that dispatch on the last path segment of the request. Put them in a catch-all route file.
// app/api/auth/[action]/route.tsimport { signSession } from '@tdacorp/identity-sdk/sealed'import { createNextAuthHandlers } from '@tdacorp/identity-sdk/rp/next'import { rp } from './relying-party'const cookieSecret = process.env.APP_COOKIE_SECRET ?? '' // at least 32 bytes// One process only. A shared store replaces this when the app runs on several instances.const endedSessions = new Set<string>()export const { GET, POST } = createNextAuthHandlers({ rp, appOrigin: 'https://app.example.com', cookieSecret, scope: 'openid profile email offline_access', defaultReturnTo: '/dashboard', // Runs after the SDK has checked the callback and verified the ID token. onLogin: async (login) => { // Persist login.tokens.refreshToken on your server before answering: it rotates on every refresh. const session = await signSession( { sub: login.idToken.sub, ...(login.idToken.sid === null ? {} : { sid: login.idToken.sid }) }, { secret: cookieSecret, ttlSeconds: 3600, purpose: 'app:session' }, ) return new Response(null, { status: 303, headers: { location: login.redirectTo, 'set-cookie': `app_session=${session.value}; Path=/; Max-Age=${String(session.maxAge)}; HttpOnly; SameSite=Lax; Secure`, }, }) }, logout: { postLogoutRedirectUri: 'https://app.example.com/signed-out', clearSessionCookies: () => ['app_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure'], }, backchannelLogout: { // Make this idempotent: the issuer retries when you answer with an error. onLogout: (token) => { if (token.sid !== null) endedSessions.add(token.sid) }, },})| Path | Method | What it does |
|---|---|---|
/api/auth/login | GET | Pushes the authorization request and redirects the browser to identity. |
/api/auth/callback | GET | Checks the response, exchanges the code, verifies the ID token and calls your onLogin. |
/api/auth/logout | POST | Ends your session and redirects to identity's end-session address. Only mounted when you pass logout. |
/api/auth/backchannel-logout | POST | Receives logout tokens from identity. Only mounted when you pass backchannelLogout. |
An unknown last segment answers 404. The helpers read the path of the request only, never its host: every absolute address they produce comes from appOrigin and the registered redirect URI.
4. Sign in once#
- Start your app and open
/api/auth/login?returnTo=/dashboard. ThereturnTovalue must be a path on your own app; anything else is ignored. - Identity shows its own sign-in page. A third-party client then shows a consent screen the first time, listing the scopes you asked for. A first-party client skips it.
- Identity redirects back to
/api/auth/callback. If every check passes,onLoginruns with the verified ID token and the tokens. In the sample it sets a session cookie and redirects toreturnTo.
If the callback answers with an error code (400, or 500 for internal_error), the code names the step that failed (for example state_mismatch or token_verification_failed). The default body never repeats text from identity.
What to do next#
- Decide what you persist: Log users in.
- See the same flow as plain HTTP: Raw HTTP walkthrough.
- Before you launch: Going live checklist.
- Understand the DPoP key: DPoP.
- Protect an API of your own: Protect your API.