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,logoutandbackchannel-logout(App Router), running Authorization Code + PKCE withstateandnonce. - An
HttpOnly, encrypted session cookie the server writes. JavaScript on the page never sees a token; a forged cookie is refused. id_tokenverified against the tenant JWKS on login, sosession.useris 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
Beareraccess token your API receives. This guide is for a Next.js app that logs users in and calls APIs on their behalf.
Prerequisites
- A Client for your app. Register:
- Allowed callback URLs:
https://app.example.com/auth/callback(andhttp://localhost:3000/auth/callbackfor development). - Logout URLs:
https://app.example.com/— where users land after logout. - Back-channel logout URI:
https://app.example.com/auth/backchannel-logout.
- Allowed callback URLs:
- Node 20+ or the Edge runtime, Next.js 13+.
npm install @faable/auth-jsSet 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.comopenssl 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.handlersThat mounts:
| Route | What it does |
|---|---|
GET /auth/login | Starts 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/callback | Exchanges the code, verifies the id_token, sets the session cookie, redirects to returnTo. |
GET /auth/logout | Clears 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-logout | Receives 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 plainhttp://base URLs),SameSite=Lax, and encrypted with a key derived fromFAABLE_AUTH_SECRET. Values that do not fit one cookie are split acrossfaable_session.0,.1, … and reassembled by the helper. returnToaccepts only paths of this app (or URLs onbaseUrl’s origin); anything else falls back to/.- The login transaction (
state,nonce, PKCE verifier) lives in a separate 10-minute cookie. A callback whosestatedid 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.
Related
- Next.js Quickstart — the client-side login flow.
- RP-Initiated Logout — front- and back-channel logout at the tenant.
- Validate Access Tokens — verify
Bearertokens your API receives. - Authorization Code Flow with PKCE — what runs under the hood.
Last updated on