Skip to Content
🔐 Faable AuthQuickstartsNext.js (Server-Side)

Next.js — Authentication on the Server

The Next.js Quickstart runs the login flow in the browser: the SDK stores the session and React hooks (useSession, useUser) read it client-side. That is enough for most apps, but the browser holds the tokens, and the server cannot stop protected content from being rendered for an anonymous request.

@faable/auth-js/nextjs moves the flow to the server:

  • Route Handlers for login, callback, logout and backchannel-logout (App Router), running Authorization Code + PKCE with state and nonce.
  • An HttpOnly, encrypted session cookie the server writes. JavaScript on the page never sees a token; a forged cookie is refused.
  • id_token verified against the tenant JWKS on login, so session.user is what the tenant signed.
  • getSession() / getAccessToken() in Server Components, Route Handlers, Server Actions and middleware — with the access token refreshed on the server when it expires.
  • An OIDC Back-Channel Logout receiver: a sign-out in another app of the same SSO session ends this one on the server, browser or not.

This is different from Validate Access Tokens. That guide verifies a Bearer access token your API receives. This guide is for a Next.js app that logs users in and calls APIs on their behalf.

Prerequisites

  1. A Client for your app. Register:
    • Allowed callback URLs: https://app.example.com/auth/callback (and http://localhost:3000/auth/callback for development).
    • Logout URLs: https://app.example.com/ — where users land after logout.
    • Back-channel logout URI: https://app.example.com/auth/backchannel-logout.
  2. Node 20+ or the Edge runtime, Next.js 13+.
npm install @faable/auth-js

Set the environment:

FAABLE_AUTH_DOMAIN=your-tenant.auth.faable.link FAABLE_AUTH_CLIENT_ID=<client_id> FAABLE_AUTH_CLIENT_SECRET=<client_secret> # confidential clients only FAABLE_AUTH_SECRET=<at least 32 random characters> # encrypts the session cookie FAABLE_AUTH_BASE_URL=https://app.example.com

openssl rand -base64 32 makes a good FAABLE_AUTH_SECRET. Rotating it signs every user out.

Set up

// lib/faable-auth.ts import { createFaableAuth } from '@faable/auth-js/nextjs' // Reads FAABLE_AUTH_* from the environment; pass an object to override. export const faableAuth = createFaableAuth()
// app/auth/[...faable]/route.ts import { faableAuth } from '@/lib/faable-auth' export const { GET, POST } = faableAuth.handlers

That mounts:

RouteWhat it does
GET /auth/loginStarts the login. ?returnTo=/dashboard is where the user lands afterwards (paths of this app only). Forwards connection, prompt, login_hint, screen_hint, audience, scope, acr_values, login_methods to /authorize.
GET /auth/callbackExchanges the code, verifies the id_token, sets the session cookie, redirects to returnTo.
GET /auth/logoutClears the cookie and sends the browser through the tenant’s /logout (with id_token_hint), so the SSO session ends and every other app is told. ?returnTo=/bye must be a registered logout URL.
POST /auth/backchannel-logoutReceives the tenant’s logout_token, verifies it, and ends the session it names.

Link to them from your UI: <a href="/auth/login?returnTo=/dashboard">Sign in</a> and <a href="/auth/logout">Sign out</a>.

Read the session

// app/dashboard/page.tsx — Server Component import { faableAuth } from '@/lib/faable-auth' import { redirect } from 'next/navigation' export default async function DashboardPage() { const session = await faableAuth.getSession() if (!session) redirect('/auth/login?returnTo=/dashboard') return <h1>Welcome, {session.user.email}</h1> }

session.user is the verified id_token — sub, email, name, picture and any custom claim the tenant adds. getSession() reads the cookie from next/headers when called with no argument; pass a Request (middleware, Route Handlers) or a cookie jar (req.cookies) to read it from there.

Call an API on the user’s behalf

// app/api/orders/route.ts import { faableAuth } from '@/lib/faable-auth' export async function GET() { const token = await faableAuth.getAccessToken() if (!token) return new Response(null, { status: 401 }) return fetch('https://api.example.com/orders', { headers: { Authorization: `Bearer ${token.accessToken}` } }) }

getAccessToken() refreshes the token with the stored refresh token when it is about to expire and writes the new session back to the cookie where Next.js allows it (Route Handlers, Server Actions, middleware). In a Server Component — which cannot set cookies — the refreshed token is still returned for that render; the cookie is updated the next time a handler runs. When the refresh is refused (invalid_grant: the session was revoked elsewhere) the result is null.

Request an audience for a specific API with createFaableAuth({ authorizationParams: { audience: 'https://api.example.com' } }).

Gate routes in middleware.ts

// middleware.ts import { faableAuth } from '@/lib/faable-auth' import type { NextRequest } from 'next/server' export const middleware = (req: NextRequest) => faableAuth.middleware(req, { protect: req => req.nextUrl.pathname.startsWith('/dashboard') }) export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }

The middleware keeps the session fresh on every request and sends anonymous visitors of the protected paths to /auth/login with returnTo set. Everything else passes through.

Back-channel logout

When the user signs out of another application in the same SSO session — or revokes this session from another device — the tenant POSTs a logout_token to /auth/backchannel-logout. The handler verifies it (signature against the JWKS, iss, aud, iat, the events claim, sid) and records the session as ended. From then on getSession() returns null even though the browser still sends the cookie, and the next page load lands on your login.

The record lives in a SessionStore. The default keeps it in the memory of the Node process, which is correct for one instance. With several instances — or on the Edge — pass a shared one:

import { createFaableAuth, type SessionStore } from '@faable/auth-js/nextjs' import { redis } from '@/lib/redis' const sessionStore: SessionStore = { async revoke({ sid, sub, iat, exp }) { const ttl = Math.max(exp - Math.floor(Date.now() / 1000), 60) if (sid) await redis.set(`revoked:sid:${sid}`, '1', { EX: ttl }) else if (sub) await redis.set(`revoked:sub:${sub}`, String(iat), { EX: ttl }) }, async isRevoked({ sid, sub, iat }) { if (sid && (await redis.exists(`revoked:sid:${sid}`))) return true const cut = await redis.get(`revoked:sub:${sub}`) return cut !== null && iat <= Number(cut) } } export const faableAuth = createFaableAuth({ sessionStore })

See RP-Initiated Logout for what the token contains and how the tenant delivers it.

Options

createFaableAuth({ domain, clientId, clientSecret, secret, baseUrl, // or FAABLE_AUTH_* routes: { login: '/auth/login', callback: '/auth/callback', logout: '/auth/logout', backchannelLogout: '/auth/backchannel-logout' }, cookie: { name: 'faable_session', sameSite: 'lax', secure: true, maxAge: 7 * 24 * 3600, domain: undefined }, authorizationParams: { scope: 'openid profile email', audience: undefined, connection: undefined }, sessionStore, onLogin: async ({ session, returnTo, request }) => { /* return a Response to take over the redirect */ } })

Security notes

  • The session cookie is HttpOnly, Secure (except on plain http:// base URLs), SameSite=Lax, and encrypted with a key derived from FAABLE_AUTH_SECRET. Values that do not fit one cookie are split across faable_session.0, .1, … and reassembled by the helper.
  • returnTo accepts only paths of this app (or URLs on baseUrl’s origin); anything else falls back to /.
  • The login transaction (state, nonce, PKCE verifier) lives in a separate 10-minute cookie. A callback whose state did not start here is refused.

Using the browser SDK too

You can keep @faable/auth-js in the browser for its hooks and getTokenSilently() — the two share the SSO session at the tenant, not the cookie. For a page that only needs the user’s name and email, prefer reading the server session and passing it down as props.

Legacy: getSessionFromCookies (deprecated)

Earlier versions read the browser SDK’s cookie (storage: 'cookie') on the server with getSessionFromCookies. That cookie is written by JavaScript, so it is neither HttpOnly nor verified — anything on the page, or a forged request, can put an arbitrary session in it. It still works for convenience, but it must never be the basis of an authorization decision. New apps use the entry above.

FAQ

Do I still need the client-side provider?

Only if you use the React hooks or start logins from client components. Server-side auth works on its own, and most server-rendered apps do not need the provider at all.

Why does getSession() return null right after I logged in?

Check that FAABLE_AUTH_BASE_URL matches the origin the browser used (cookies are per host), that the callback URL is registered exactly, and that the secret is the same in every instance of the app.

Can I use this in Edge middleware?

Yes. The helper uses only Web APIs (fetch, Web Crypto) through jose. Use a shared SessionStore there: the in-memory default is per isolate.

Last updated on