Next.js quickstart
The Next.js SDK signs players in on your Next.js server. Tokens stay on the server, in an encrypted cookie, and your pages read the player from the session.
Before you start
- A Next.js 15.2 or later app that uses the App Router, on Node.js, the Edge runtime or Cloudflare (OpenNext).
- A project with a Browser application. See Console onboarding. Your Next.js server acts as that application: a public client that proves each sign-in with PKCE, so there is no client secret to store.
- The application's Client ID and the Project ID from Integration settings.
https://your-app/auth/callbackregistered under Redirect URIs. The token, revocation and discovery calls come from your server, not the browser, so they need no allowed origin.
The consent screen
After signing in, hosted login asks players to allow the application to access their account. Your own site does not need that step. On the application's page in the console, turn on First-party application under Consent screen and save. Players then return to /auth/callback straight after signing in, and their consent is recorded as if they had chosen Allow. The screen is still shown when the redirect URI is not https on a public host (for examplehttp://localhost:3000 during development) and when a request asks forprompt=consent. See the first-party reference.
How sign-in works
- A player opens
/auth/login. The SDK creates a state, a nonce and a PKCE verifier, seals them in a short-lived encrypted cookie, and redirects to the hosted login page. - After sign-in, hosted login redirects to
/auth/callbackwith a one-time code. The SDK checks the state against the cookie, exchanges the code with the verifier from your server, and verifies the ID token (signature, issuer, audience, expiry and nonce). - The session is stored in an encrypted
HttpOnly,SameSite=Laxcookie keyed byAUTH4_SECRET, split into chunks when it is larger than about 3.5 KB. The access token, and the refresh token if you allow one, are inside it. Client JavaScript can't read it.
1. Add the SDK
Install @auth4/nextjs from npm. It depends only on jose and uses the Web Crypto API, and it needs next 15.2 or later and react 19 as peer dependencies.
npm install @auth4/nextjsThe source is in packages/sdk-nextjs and in the public SDK repository. The example in this repository uses the TypeScript source, so it lists the package in transpilePackages; the npm package is already built and doesn't need that.
Server code imports @auth4/nextjs/server; client components import @auth4/nextjs.
2. Configure
Set these variables in .env.local and in your host's settings. Never commitAUTH4_SECRET. Changing it signs every player out.
AUTH4_ORIGIN=https://auth.auth4.dev
AUTH4_TENANT_ID=ten_…
AUTH4_CLIENT_ID=cli_…
APP_BASE_URL=https://play.example.com
AUTH4_SECRET=… # at least 32 random characters, for example: openssl rand -hex 32
AUTH4_REFRESH=true # only if the application allows refresh tokens in the browser| Option | Variable | Meaning |
|---|---|---|
authOrigin | AUTH4_ORIGIN | The auth origin, by default https://auth.auth4.dev. |
tenantId | AUTH4_TENANT_ID | Your project ID. The issuer is {authOrigin}/t/{tenantId}. |
clientId | AUTH4_CLIENT_ID | The browser application's client ID. |
appBaseUrl | APP_BASE_URL | Your app's origin. It must use https, except on localhost. |
secret | AUTH4_SECRET | At least 32 characters. Pass an array to rotate it: the first secret encrypts, and every secret still decrypts. |
refresh | (option only) | Keep a refresh token in the session. See Refresh. |
scope | (option only) | openid profile email by default. |
3. Create the client
Create one Auth4Client and import it wherever you need it. It reads the variables on first use, so next build works before they are set:
examples/nextjs/lib/auth4.tsimport { Auth4Client } from "@auth4/nextjs/server";
// Reads AUTH4_ORIGIN, AUTH4_TENANT_ID, AUTH4_CLIENT_ID, APP_BASE_URL and AUTH4_SECRET.
export const auth4 = new Auth4Client({
// Only when the application has "Allow refresh tokens in the browser" turned on.
refresh: process.env["AUTH4_REFRESH"] === "true",
});4. Add the proxy
middleware() answers the /auth/* routes. On every other matched request it refreshes the access token shortly before it expires and keeps the session alive. Next.js 16 calls this file proxy.ts; on Next.js 15, name it middleware.tsand export middleware:
examples/nextjs/proxy.tsimport type { NextRequest } from "next/server";
import { auth4 } from "./lib/auth4";
// Next.js 16 calls this file proxy.ts; on Next.js 15 name it middleware.ts and the function
// middleware.
export async function proxy(request: NextRequest) {
return auth4.middleware(request);
}
export const config = {
// Everything except static assets, so /auth/* is answered and sessions are refreshed.
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt).*)"],
};| Route | What it does |
|---|---|
/auth/login | Starts sign-in. returnTo is where to go afterwards; it must be on your app's origin, and anything else goes to /. provider opens discord,steam, epic, google, apple ormicrosoft first. |
/auth/callback | Finishes sign-in. Register this URL as a redirect URI. |
/auth/logout | Revokes the tokens, clears the session and returns to returnTo on your origin. |
/auth/profile | The signed-in user as JSON, or 204 when signed out. Never a token. |
/auth/access-token | Off by default. With enableAccessTokenEndpoint: true it returns the access token to your own pages. See Tokens in the browser. |
Link to these routes with plain <a> elements. A<Link> prefetches its target, and prefetching sign-out would sign the player out.
5. Read the session
In server components, route handlers and server actions, getSession() returns the session or null. It holds the user and the access token's expiry, never the tokens. A protected page redirects signed-out players to sign-in:
examples/nextjs/app/vault/page.tsximport { redirect } from "next/navigation";
import { auth4 } from "../../lib/auth4";
export default async function Vault() {
const session = await auth4.getSession();
if (!session) redirect("/auth/login?returnTo=/vault");
const expires = new Date(session.accessTokenExpiresAt * 1_000).toISOString();
return (
<>
<h1>Your vault</h1>
<dl className="facts">
<dt>Player</dt>
<dd data-testid="player">{session.user.email ?? session.user.sub}</dd>
<dt>Access token valid until</dt>
<dd data-testid="expires">{expires}</dd>
<dt>Stays signed in after the access token expires</dt>
<dd>{session.refreshable ? "Yes, refreshed on the server" : "No"}</dd>
</dl>
</>
);
}For client components, wrap the app in Auth4Provider in the root layout, passing the user you already have so the first render needs no request:
examples/nextjs/app/layout.tsxexport default async function RootLayout({ children }: { children: ReactNode }) {
const session = await auth4.getSession();
return (
<html lang="en">
<body>
{/* Only the user's profile claims reach the browser, never a token. */}
<Auth4Provider user={session?.user ?? null}>
<header className="bar">
<a className="brand" href="/">
Gem vault
</a>
<nav aria-label="Main">
<a href="/vault">Vault</a>
{/* A plain link: <Link> would prefetch sign-out. */}
{session ? <a href="/auth/logout">Sign out</a> : <a href="/auth/login">Sign in</a>}
</nav>
</header>Then useUser() gives user, isLoading,error and invalidate(). Without a provider it asks/auth/profile itself.
examples/nextjs/app/player-badge.tsx"use client";
import { useUser } from "@auth4/nextjs";
export function PlayerBadge() {
const { user, isLoading } = useUser();
if (isLoading) return <p role="status">Loading…</p>;
if (!user) return <p role="status">You are signed out.</p>;
return <p role="status">Signed in as {user.email ?? user.sub}.</p>;
}6. Call your API
Call your game API from the server. getAccessToken() returns a live access token and refreshes it first when needed. Your API verifies it with a server SDK:
// app/api/gems/route.ts
import { AccessTokenError } from "@auth4/nextjs/server";
import { auth4 } from "@/lib/auth4";
export async function GET() {
try {
const { token } = await auth4.getAccessToken();
const response = await fetch("https://api.example.com/gems", {
headers: { Authorization: `Bearer ${token}` },
});
return Response.json(await response.json());
} catch (error) {
if (error instanceof AccessTokenError) {
return Response.json({ error: error.code }, { status: 401 });
}
throw error;
}
}It throws AccessTokenError with a code:not_signed_in, expired, refresh_rejected (the player has been signed out), refresh_failed (auth4.dev was briefly unreachable) orread_only.
Refresh
Access tokens last five minutes. To keep players signed in longer, turn on Allow refresh tokens in the browser on the application's page, add offline_access under Scopes, save, and create the client with refresh: true. The SDK then requestsoffline_access and keeps the rotating refresh token in the session cookie. Without it, the player stays signed in to your app until the session cookie expires, butgetAccessToken() fails with expired five minutes after sign-in.
- The proxy refreshes in the last minute of the access token, before your page renders, and passes the new session to that same request. Server components can't set cookies, so they never refresh: if the proxy doesn't run for a path,
getAccessToken()there throwsread_onlyonce the token has expired. - Each refresh token works once, apart from the30-second reuse window. If an old one is used again after that, auth4.dev ends the whole session, and the SDK signs the player out on their next request.
- Parallel requests that carry the same refresh token share one refresh within one server instance. Several instances refreshing the same session at the same moment each get a working token from the reuse window.
- The session cookie ends after one day without a request (
session.rollingDuration) and seven days after sign-in (session.absoluteDuration), matching the browser refresh token lifetimes.
Sign-out
/auth/logout revokes the refresh token and the access token at the revocation endpoint, which ends the player's auth4.dev session, and clears the cookie. It refuses requests that start on another site. With hostedLogout: true it also shows the hosted sign-out page, which returns through /auth/callback, so no extra address needs registering.
Tokens in the browser
By default no token reaches client JavaScript. If a client component must call an API directly, turn on enableAccessTokenEndpoint and use the client getAccessToken(). The route refuses cross-site requests and sends Cache-Control: no-store:
// Only with enableAccessTokenEndpoint: true on the Auth4Client.
import { getAccessToken } from "@auth4/nextjs";
const { token } = await getAccessToken();Callback errors
A callback with a missing, expired or foreign state, an issuer mismatch or an ID token that fails verification answers 400 with a short message, and no session is created. The issuer's error description is never shown. To show your own page, pass onCallbackError, which receives one of invalid_state, invalid_callback,access_denied, authorization_failed, token_exchange_failedor invalid_id_token.
Coming from Auth0
The SDK follows the shape of @auth0/nextjs-auth0 v4, so most of a migration is renaming. Players start fresh on auth4.dev: there is no user import, so they sign up again.
// Before: @auth0/nextjs-auth0 v4
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client();
// middleware.ts
export async function middleware(request: NextRequest) {
return await auth0.middleware(request);
}// After: @auth4/nextjs
import { Auth4Client } from "@auth4/nextjs/server";
export const auth4 = new Auth4Client({ refresh: true });
// proxy.ts (or middleware.ts on Next.js 15)
export async function proxy(request: NextRequest) {
return auth4.middleware(request);
}| @auth0/nextjs-auth0 v4 | @auth4/nextjs |
|---|---|
AUTH0_DOMAIN | AUTH4_ORIGIN and AUTH4_TENANT_ID |
AUTH0_CLIENT_ID | AUTH4_CLIENT_ID |
AUTH0_CLIENT_SECRET | Not used: the app is a public client and proves sign-ins with PKCE. |
AUTH0_SECRET | AUTH4_SECRET |
APP_BASE_URL | APP_BASE_URL |
Auth0Client from @auth0/nextjs-auth0/server | Auth4Client from @auth4/nextjs/server |
auth0.middleware(request) | auth4.middleware(request) |
auth0.getSession() | auth4.getSession(). The session has user but no token set; usegetAccessToken(). |
auth0.getAccessToken() | auth4.getAccessToken(), returning token, expires_at andscope. |
Auth0Provider, useUser() from @auth0/nextjs-auth0 | Auth4Provider, useUser() from @auth4/nextjs |
/auth/login?connection=… | /auth/login?provider=discord (or another provider above) |
/auth/access-token | Off unless enableAccessTokenEndpoint is on. |
Refresh tokens with the offline_access scope | refresh: true, once the application allows refresh tokens in the browser. |
Before switching, also check that:
https://your-app/auth/callbackis registered in the auth4.dev application.- If players should not see a consent screen on your own site, First-party application is on (see The consent screen).
- Your API verifies auth4.dev access tokens: the issuer is
https://auth.auth4.dev/t/ten_…and the audience is the client ID. - Code that read
session.tokenSetor custom claims usesgetAccessToken()and the claims auth4.dev provides. See Tokens and revocation. - Sign-out links point at
/auth/logoutand are plain links.
SDK notes: packages/sdk-nextjs/README.md. Example: examples/nextjs/README.md. Tests: packages/sdk-nextjs/src/client.test.ts, tests/integration/auth4d-231.nextjs.test.ts and tests/e2e/auth4d-231.nextjs.spec.ts.