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/callback registered under Redirect URIs. The token, revocation and discovery calls come from your server, not the browser, so they need no allowed origin.

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

  1. 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.
  2. After sign-in, hosted login redirects to /auth/callback with 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).
  3. The session is stored in an encrypted HttpOnly, SameSite=Lax cookie keyed by AUTH4_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/nextjs

The 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.

.env.local
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
OptionVariableMeaning
authOriginAUTH4_ORIGINThe auth origin, by default https://auth.auth4.dev.
tenantIdAUTH4_TENANT_IDYour project ID. The issuer is {authOrigin}/t/{tenantId}.
clientIdAUTH4_CLIENT_IDThe browser application's client ID.
appBaseUrlAPP_BASE_URLYour app's origin. It must use https, except on localhost.
secretAUTH4_SECRETAt 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.ts
import { 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.ts
import 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).*)"],
};
RouteWhat it does
/auth/loginStarts 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/callbackFinishes sign-in. Register this URL as a redirect URI.
/auth/logoutRevokes the tokens, clears the session and returns to returnTo on your origin.
/auth/profileThe signed-in user as JSON, or 204 when signed out. Never a token.
/auth/access-tokenOff 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.tsx
import { 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:

Excerpt from examples/nextjs/app/layout.tsx
export 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:

A route handler
// 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 throws read_only once 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_DOMAINAUTH4_ORIGIN and AUTH4_TENANT_ID
AUTH0_CLIENT_IDAUTH4_CLIENT_ID
AUTH0_CLIENT_SECRETNot used: the app is a public client and proves sign-ins with PKCE.
AUTH0_SECRETAUTH4_SECRET
APP_BASE_URLAPP_BASE_URL
Auth0Client from @auth0/nextjs-auth0/serverAuth4Client 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-auth0Auth4Provider, useUser() from @auth4/nextjs
/auth/login?connection=…/auth/login?provider=discord (or another provider above)
/auth/access-tokenOff unless enableAccessTokenEndpoint is on.
Refresh tokens with the offline_access scoperefresh: true, once the application allows refresh tokens in the browser.

Before switching, also check that:

  • https://your-app/auth/callback is 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 ishttps://auth.auth4.dev/t/ten_… and the audience is the client ID.
  • Code that read session.tokenSet or custom claims uses getAccessToken()and the claims auth4.dev provides. See Tokens and revocation.
  • Sign-out links point at /auth/logout and 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.