Docs menu

Next.js

Add Sign in with PulseID to a Next.js app with plain OIDC and PKCE.

This guide adds Sign in with PulseID to a Next.js 15 app with the app router, route handlers, openid-client 6 and jose 6. There is nothing else to install and no PulseID package: it is the code flow with PKCE, written out. The quickstart shows the same flow with curl, and the reference lists every endpoint, scope and claim.

The worked example is ByteSizedBites, WarPulse's recipe site. It signs people in this way, with openid-client and a sealed session cookie, and keeps nothing but who the person is. The code below is the same shape, plus the optional refresh step for apps that keep tokens.

1. Register the app

  1. In the portal, register a web app. Its redirect URI is your site plus /auth/callback, for example https://bytesizedbites.example/auth/callback. Add your site's home page as a post-logout redirect URI.
  2. Copy the client ID and the client secret. The secret is shown once. If you lose it, rotate it (see Secrets).

Redirect URIs must be https, and PulseID refuses http://localhost. To develop locally, put the app behind an https address: a tunnel, or a local certificate on a name you own. Register that address.

Refresh tokens. A refresh token goes to a verified web app only. Unverified apps get no refresh token. Public apps get no refresh token either, even when their organization is verified. PulseID removes offline_access from the request without an error, so the sign-in still works and the answer holds no refresh_token. The code below handles both. Leave PULSEID_OFFLINE_ACCESS at false until your organization is verified (see Verification).

2. Set the environment

Put these in .env.local. The issuer is https://id.warpulse.com; the code finds every endpoint from its discovery document at https://id.warpulse.com/.well-known/openid-configuration.

.env.local
PULSEID_ISSUER=https://id.warpulse.com
PULSEID_CLIENT_ID=YOUR_CLIENT_ID
PULSEID_CLIENT_SECRET=YOUR_CLIENT_SECRET
PULSEID_REDIRECT_URI=https://bytesizedbites.example/auth/callback
# true only for a verified web app: it asks for offline_access and prompt=consent
PULSEID_OFFLINE_ACCESS=false
SESSION_SECRET=A_RANDOM_VALUE_FROM_THE_COMMAND_ABOVE
Environment
NameWhat it is
PULSEID_ISSUERThe issuer. It is the address of PulseID.
PULSEID_CLIENT_IDThe client ID from the portal.
PULSEID_CLIENT_SECRETThe client secret from the portal. Keep it on the server. Never put it in a file a browser loads.
PULSEID_REDIRECT_URIThe redirect URI you registered, exactly. The callback path is /auth/callback.
PULSEID_OFFLINE_ACCESStrue only for a verified web app. It asks for offline_access with prompt=consent, to receive a refresh token.
SESSION_SECRETA random 32-byte value, base64url, that seals the cookies. Make one with the command below.
Make a SESSION_SECRET
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

3. The shared module

lib/pulseid.ts holds everything the routes share: the settings, discovery, the sealed cookies, and the refresh. Read it once from top to bottom, as the routes below only call it.

  • Discovery. client.discovery reads the discovery document, and the client secret goes to the token endpoint with HTTP Basic (client_secret_basic), which is how a web app is registered.
  • Sealed cookies. The session and the login-in-flight are encrypted with jose (JWE, dir and A256GCM) under a key derived from SESSION_SECRET for each purpose. The browser can neither read nor change them, and a login cookie cannot be played back as a session. The names start with __Host-, which browsers accept only on https, from your own host, for the whole site.
  • Refreshing. A Server Component cannot set a cookie, so a page that finds the access token about to expire hands over to /auth/refresh, a route handler that can. See step 7.
lib/pulseid.ts
import { hkdfSync } from 'node:crypto'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { EncryptJWT, jwtDecrypt } from 'jose'
import * as client from 'openid-client'

export const REDIRECT_URI = process.env.PULSEID_REDIRECT_URI!
// Refresh tokens come with offline_access, and only a verified web app gets one.
export const OFFLINE = process.env.PULSEID_OFFLINE_ACCESS === 'true'
export const SCOPE = OFFLINE ? 'openid profile email offline_access' : 'openid profile email'

export const SESSION_COOKIE = '__Host-session'
export const LOGIN_COOKIE = '__Host-login'
export const SESSION_SECONDS = 30 * 24 * 3600
export const LOGIN_SECONDS = 600
const REFRESH_MARGIN_SECONDS = 60
const REFRESH_SHARE_MS = 30 * 1000

export type Session = {
  sub: string
  email: string | null
  name: string | null
  accessToken: string
  refreshToken: string | null
  accessExpires: number // epoch seconds
}
export type Login = { verifier: string; state: string; nonce: string; returnTo: string }
type Tokens = Awaited<ReturnType<typeof client.authorizationCodeGrant>>

// Discovery: the endpoints and the signing keys, read once and kept. The client secret goes to the token
// endpoint with HTTP Basic (client_secret_basic), which is how a web app is registered.
let configuration: Promise<client.Configuration> | undefined
export function discover(): Promise<client.Configuration> {
  configuration ??= client
    .discovery(new URL(process.env.PULSEID_ISSUER!), process.env.PULSEID_CLIENT_ID!, undefined, client.ClientSecretBasic(process.env.PULSEID_CLIENT_SECRET!))
    .catch((error) => {
      configuration = undefined
      throw error
    })
  return configuration
}

// A sealed cookie: the payload is encrypted (JWE, dir + A256GCM) under a key made from SESSION_SECRET for one purpose,
// so the browser can neither read nor change it, and a login cookie cannot be played back as a session.
const key = (purpose: string) => new Uint8Array(hkdfSync('sha256', Buffer.from(process.env.SESSION_SECRET!, 'base64url'), '', 'bytesizedbites:' + purpose, 32))

export async function seal(purpose: string, data: object, seconds: number): Promise<string> {
  const now = Math.floor(Date.now() / 1000)
  return new EncryptJWT({ p: purpose, d: data }).setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }).setIssuedAt(now).setExpirationTime(now + seconds).encrypt(key(purpose))
}

export async function unseal<T>(purpose: string, value: string | undefined): Promise<T | null> {
  if (!value) return null
  try {
    const { payload } = await jwtDecrypt(value, key(purpose), { keyManagementAlgorithms: ['dir'], contentEncryptionAlgorithms: ['A256GCM'] })
    return payload.p === purpose ? (payload.d as T) : null
  } catch {
    return null
  }
}

export const cookieOptions = (maxAge: number) => ({ httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/', maxAge })

// Only ever send the person back to a path on this site: one slash, then no second slash, backslash, space or control character
// (a browser drops a tab, so "/<tab>/evil.com" would mean "//evil.com"), and one that still resolves to this origin.
const SITE = 'https://site.example'
export const safePath = (value: string | null): string => {
  if (!value || !/^\/(?![/\\])[^\u0000-\u0020\u007f\\]*$/.test(value)) return '/'
  return new URL(value, SITE).origin === SITE ? value : '/'
}

export function newSession(tokens: Tokens): Session {
  const claims = tokens.claims()
  if (!claims) throw new Error('PulseID sent no ID token')
  const text = (value: unknown) => (typeof value === 'string' ? value : null)
  return {
    sub: claims.sub,
    email: text(claims.email),
    name: text(claims.name),
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token ?? null,
    accessExpires: Math.floor(Date.now() / 1000) + (tokens.expiresIn() ?? 600),
  }
}

export function refreshed(session: Session, tokens: Tokens): Session {
  return {
    ...session,
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token ?? session.refreshToken,
    accessExpires: Math.floor(Date.now() / 1000) + (tokens.expiresIn() ?? 600),
  }
}

// A refresh token works once: the answer holds a new one. Use a token a second time and PulseID ends the whole sign-in.
// Requests that arrive together carry the same token, so they share one refresh. (This is one server process. With
// several, refresh behind a lock they all share.)
const refreshes = new Map<string, Promise<Tokens>>()
export function refreshOnce(refreshToken: string): Promise<Tokens> {
  let pending = refreshes.get(refreshToken)
  if (!pending) {
    pending = discover().then((config) => client.refreshTokenGrant(config, refreshToken))
    refreshes.set(refreshToken, pending)
    pending.catch(() => refreshes.delete(refreshToken))
    setTimeout(() => refreshes.delete(refreshToken), REFRESH_SHARE_MS).unref()
  }
  return pending
}

export async function getSession(): Promise<Session | null> {
  return unseal<Session>('session', (await cookies()).get(SESSION_COOKIE)?.value)
}

// For a page that needs a signed-in person. A Server Component cannot set a cookie, and a refresh must store the
// new refresh token, so a page that finds the access token about to expire hands over to /auth/refresh.
export async function requireSession(returnTo: string): Promise<Session> {
  const session = await getSession()
  const back = encodeURIComponent(returnTo)
  if (!session) redirect('/auth/login?returnTo=' + back)
  if (session.refreshToken && session.accessExpires - Date.now() / 1000 <= REFRESH_MARGIN_SECONDS) redirect('/auth/refresh?returnTo=' + back)
  return session
}

4. Send the person to PulseID

/auth/login makes a random code verifier, a state and a nonce, and builds the authorization URL with the PKCE challenge: code_challenge_method=S256, which is the only method PulseID has. With PULSEID_OFFLINE_ACCESS on it also sends offline_access and prompt=consent. Send prompt=consent whenever you ask for offline_access: it is what yields the refresh token. The verifier, state and nonce wait in a sealed cookie that lasts ten minutes, until the person comes back.

app/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import * as client from 'openid-client'
import { LOGIN_COOKIE, LOGIN_SECONDS, OFFLINE, REDIRECT_URI, SCOPE, cookieOptions, discover, safePath, seal } from '@/lib/pulseid'
import type { Login } from '@/lib/pulseid'

export async function GET(req: NextRequest) {
  const config = await discover()
  const verifier = client.randomPKCECodeVerifier()
  const state = client.randomState()
  const nonce = client.randomNonce()

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: REDIRECT_URI,
    scope: SCOPE,
    // offline_access is only honoured together with prompt=consent.
    ...(OFFLINE ? { prompt: 'consent' } : {}),
    code_challenge: await client.calculatePKCECodeChallenge(verifier),
    code_challenge_method: 'S256',
    state,
    nonce,
  })

  // Remember the secrets of this sign-in in a short-lived sealed cookie, until the person comes back.
  const login: Login = { verifier, state, nonce, returnTo: safePath(req.nextUrl.searchParams.get('returnTo')) }
  const res = NextResponse.redirect(url, 303)
  res.cookies.set(LOGIN_COOKIE, await seal('login', login, LOGIN_SECONDS), cookieOptions(LOGIN_SECONDS))
  return res
}

5. Handle the return

PulseID sends the person to /auth/callback with code, state and iss. One call to authorizationCodeGrant does all of this: it checks the state, sends the code with the PKCE verifier and the client secret (the code works once and for 60 seconds), and validates the ID token. The ID token is a signed JWT with a 10 minute life. It checks the signature against the keys at the jwks_uri, the issuer, the audience (your client ID), the expiry and the nonce, and it throws if any of them is wrong. The ID token is the identity: sub is the person, stable for that account, and email and name come with the email and profile scopes. Use sub as your key for the person, not the email address.

app/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import * as client from 'openid-client'
import { LOGIN_COOKIE, REDIRECT_URI, SESSION_COOKIE, SESSION_SECONDS, cookieOptions, discover, newSession, safePath, seal, unseal } from '@/lib/pulseid'
import type { Login } from '@/lib/pulseid'

export async function GET(req: NextRequest) {
  // The login cookie is good for one return, whatever happens.
  const finish = (res: NextResponse) => {
    res.cookies.set(LOGIN_COOKIE, '', cookieOptions(0))
    return res
  }
  const login = await unseal<Login>('login', req.cookies.get(LOGIN_COOKIE)?.value)
  if (!login) return finish(new NextResponse('The sign-in expired. Start again.', { status: 400 }))

  try {
    // The registered redirect URI plus the query PulseID sent back (code, state, iss), not the Host header.
    const currentUrl = new URL(REDIRECT_URI)
    currentUrl.search = req.nextUrl.search

    // One call checks state, exchanges the code with the PKCE verifier and the client secret, and validates the ID token:
    // signature through the JWKS, issuer, audience, expiry and nonce. It throws on anything wrong, an error response included.
    const tokens = await client.authorizationCodeGrant(await discover(), currentUrl, {
      pkceCodeVerifier: login.verifier,
      expectedState: login.state,
      expectedNonce: login.nonce,
      idTokenExpected: true,
    })

    const res = NextResponse.redirect(new URL(safePath(login.returnTo), REDIRECT_URI), 303)
    res.cookies.set(SESSION_COOKIE, await seal('session', newSession(tokens), SESSION_SECONDS), cookieOptions(SESSION_SECONDS))
    return finish(res)
  } catch (error) {
    // Log the code and message only, never the whole error: it can hold the response body.
    console.error('PulseID sign-in failed:', (error as { code?: string }).code, (error as Error).message)
    return finish(new NextResponse('Sign-in failed. Start again.', { status: 400 }))
  }
}

The access token is opaque. It is good for the userinfo endpoint for 10 minutes and tells you nothing by itself. If all you need is who the person is, as ByteSizedBites does, keep sub and the profile and drop the tokens from the session.

6. Use the session

requireSession reads the cookie and sends a signed-out person to /auth/login. Check the session in every page and route that needs it, not only in a layout. The sign-out button is a form that posts to /auth/logout.

app/account/page.tsx
import { requireSession } from '@/lib/pulseid'

export const dynamic = 'force-dynamic'

export default async function Account() {
  const session = await requireSession('/account')
  return (
    <main>
      <p>Signed in as {session.name ?? session.email ?? session.sub}.</p>
      <form action="/auth/logout" method="post">
        <button type="submit">Sign out</button>
      </form>
    </main>
  )
}

// And wherever you offer sign-in, a plain link (not next/link, which would prefetch the route):
//   <a href="/auth/login?returnTo=/account">Sign in with PulseID</a>

7. Refresh

With a refresh token, requireSession hands over to /auth/refresh when 60 seconds or fewer are left on the access token. The route trades the refresh token for new tokens, stores them in a fresh cookie and sends the person back. The refresh token lasts at most 30 days from the sign-in it came from, so the session cookie lasts that long too.

A refresh token works once. Every refresh returns a new refresh token and uses up the old one. Use a refresh token a second time and PulseID ends the whole sign-in, and the person has to sign in again. A page that loads with several requests at once sends the same token several times, so refreshOnce makes them share one refresh and keeps its result for 30 seconds. That holds in one server process. If you run several instances, take a lock that they all share, such as one in your database.

When a refresh fails with invalid_grant, the person signed out of PulseID, revoked the app, or the 30 days ended. The route clears the cookie and starts a new sign-in.

app/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { REDIRECT_URI, SESSION_COOKIE, SESSION_SECONDS, cookieOptions, getSession, refreshOnce, refreshed, safePath, seal } from '@/lib/pulseid'

export async function GET(req: NextRequest) {
  const returnTo = safePath(req.nextUrl.searchParams.get('returnTo'))
  const to = (path: string) => NextResponse.redirect(new URL(path, REDIRECT_URI), 303)
  const session = await getSession()
  if (!session?.refreshToken) return to('/auth/login?returnTo=' + encodeURIComponent(returnTo))

  try {
    const tokens = await refreshOnce(session.refreshToken)
    const res = to(returnTo)
    res.cookies.set(SESSION_COOKIE, await seal('session', refreshed(session, tokens), SESSION_SECONDS), cookieOptions(SESSION_SECONDS))
    return res
  } catch {
    // invalid_grant: the person signed out of PulseID, revoked the app, or the 30 days ended. Start again.
    const res = to('/auth/login?returnTo=' + encodeURIComponent(returnTo))
    res.cookies.set(SESSION_COOKIE, '', cookieOptions(0))
    return res
  }
}

8. Sign out

/auth/logout accepts a POST from your own site only. It revokes the refresh token, clears the cookie and sends the browser to PulseID's end-session endpoint, which asks the person to confirm and then ends their PulseID session in that browser. The person comes back to the post-logout redirect URI you registered. An address that is not registered is refused, so register your home page first.

app/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import * as client from 'openid-client'
import { REDIRECT_URI, SESSION_COOKIE, cookieOptions, discover, getSession } from '@/lib/pulseid'

// POST only, from a form on your own site, so another site cannot sign people out.
export async function POST(req: NextRequest) {
  if (req.headers.get('origin') !== new URL(REDIRECT_URI).origin) return new NextResponse('Forbidden', { status: 403 })

  const config = await discover()
  const session = await getSession()
  if (session?.refreshToken) {
    // Ends the grant at PulseID. If it fails, the token still expires on its own.
    await client.tokenRevocation(config, session.refreshToken, { token_type_hint: 'refresh_token' }).catch(() => undefined)
  }

  // PulseID asks the person to confirm, ends their PulseID session in this browser, and sends them back to a
  // post-logout redirect URI you registered for the app.
  const url = client.buildEndSessionUrl(config, {
    client_id: process.env.PULSEID_CLIENT_ID!,
    post_logout_redirect_uri: new URL('/', REDIRECT_URI).href,
  })
  const res = NextResponse.redirect(url, 303)
  res.cookies.set(SESSION_COOKIE, '', cookieOptions(0))
  return res
}

Try it

  1. Start the app behind your https address and open /account. You go to PulseID, sign in, see the consent screen for your organization, and come back signed in.
  2. Open the browser's cookies for your site. You see __Host-session and its value is unreadable.
  3. Sign out. Opening /account again starts a new sign-in.

If the callback answers "Sign-in failed", read your server log: invalid_client means the client ID or secret is wrong, revoked or the app is paused, and invalid_grant means the code was used or expired, or the redirect URI or verifier did not match what was sent. The reference lists the errors, and Secrets covers rotating the secret without an outage.