Examples
July 15, 2026 · View on GitHub
- Passing authorization parameters
- The
returnToparameter - Accessing the authenticated user
- Protecting a Server-Side Rendered (SSR) Page
- Protecting a Client-Side Rendered (CSR) Page
- Protect an API Route
- Protecting Server Actions
- Accessing the idToken
- Updating the session
- Getting an access token
- Multi-Factor Authentication (MFA)
- Reactive MFA Step-Up (Popup)
- Passwordless Authentication
- Passwordless OTP on Database Connections
- Passkey Authentication
- Silent authentication
- DPoP (Demonstrating Proof-of-Possession)
- mTLS (Mutual TLS Client Authentication)
- Proxy Handler for My Account and My Organization APIs
<Auth0Provider />- Hooks
- Session configuration
- Cookie Configuration
- Transaction Cookie Configuration
- Database sessions
- Using Client-Initiated Backchannel Authentication
- Connected Accounts
- Back-Channel Logout
- Session Expiry from the Upstream IdP
- Combining middleware
- ID Token claims and the user object
- Routes
- Dynamic Application Base URLs
- Testing helpers
- Programmatically starting interactive login
- The
returnToparameter - Getting access tokens for connections
- Custom Token Exchange
- Customizing Auth Handlers
- Next.js 16 Compatibility
- Multi-Factor Authentication (MFA)
- Multiple Custom Domains (MCD)
Passing authorization parameters
There are 2 ways to customize the authorization parameters that will be passed to the /authorize endpoint. The first option is through static configuration when instantiating the client, like so:
export const auth0 = new Auth0Client({
authorizationParameters: {
scope: "openid profile email",
audience: "urn:custom:api"
}
});
The second option is through the query parameters to the /auth/login endpoint which allows you to specify the authorization parameters dynamically. For example, to specify an audience, the login URL would look like so:
<a href="/auth/login?audience=urn:my-api">Login</a>
Social Login
To skip the Universal Login page and send users directly to a social provider, pass the connection parameter with the Auth0 connection name:
<a href="/auth/login?connection=google-oauth2">Continue with Google</a>
<a href="/auth/login?connection=github">Continue with GitHub</a>
<a href="/auth/login?connection=facebook">Continue with Facebook</a>
Or configure it statically for all logins:
export const auth0 = new Auth0Client({
authorizationParameters: {
connection: "google-oauth2"
}
});
Passwordless (via Universal Login)
Auth0 supports passwordless login using email magic links/OTP or SMS OTP through the Universal Login page. The SDK forwards the connection parameter to Auth0, which handles the entire passwordless flow and redirects back to your app with a standard authorization code.
Prerequisites:
- Enable the Email or SMS passwordless connection in the Auth0 Dashboard under Authentication > Passwordless.
- Add the connection to your application.
Email passwordless:
<!-- Auth0 will send a magic link or OTP to the user's email -->
<a href="/auth/login?connection=email">Login with Email</a>
SMS passwordless:
<!-- Auth0 will send an OTP to the user's phone number -->
<a href="/auth/login?connection=sms">Login with SMS</a>
The user experience on the Auth0-hosted page (magic link vs. OTP code) is configured in the Auth0 Dashboard under the connection settings. Your application code does not change — the callback and session creation work identically to any other login.
Showing the signup screen
To send users directly to the registration form instead of the login form:
<a href="/auth/login?screen_hint=signup">Create account</a>
Or statically:
export const auth0 = new Auth0Client({
authorizationParameters: {
screen_hint: "signup"
}
});
Pre-filling the login field
To pre-fill the email or phone number field on the Universal Login page:
// In a Server Action or Route Handler
const response = await auth0.startInteractiveLogin({
authorizationParameters: {
login_hint: "user@example.com"
}
});
The returnTo parameter
Redirecting the user after authentication
The returnTo parameter can be appended to the login to specify where you would like to redirect the user after they have completed their authentication and have returned to your application.
For example: /auth/login?returnTo=/dashboard would redirect the user to the /dashboard route after they have authenticated.
Note
The URL specified as returnTo parameters must be registered in your client's Allowed Callback URLs.
Redirecting the user after logging out
The returnTo parameter can be appended to the logout to specify where you would like to redirect the user after they have logged out.
For example: /auth/logout?returnTo=https://example.com/some-page would redirect the user to the https://example.com/some-page URL after they have logged out.
Note
The URL specified as returnTo parameters must be registered in your client's Allowed Logout URLs.
Configuring logout strategy
By default, the SDK uses OpenID Connect's RP-Initiated Logout when available, falling back to Auth0's /v2/logout endpoint. You can control this behavior using the logoutStrategy configuration option:
export const auth0 = new Auth0Client({
logoutStrategy: "auto" // default behavior
// ... other config
});
Available strategies:
"auto"(default): Uses OIDC logout whenend_session_endpointis available, falls back to/v2/logout"oidc": Always uses OIDC RP-Initiated Logout. Returns an error if not supported by the authorization server"v2": Always uses Auth0's/v2/logoutendpoint, which supports wildcard URLs and legacy configurations
When to use "v2" strategy
The "v2" strategy is useful for applications that:
- Need wildcard URL support in logout redirects (e.g.,
https://localhost:3000/*/about) - Support multiple languages or environments with dynamic URLs
- Were migrated from v3 and need to maintain existing logout URL patterns
- Have complex logout URL requirements that aren't compatible with OIDC logout
// Example: Using v2 logout for wildcard URL support
export const auth0 = new Auth0Client({
logoutStrategy: "v2"
// ... other config
});
// This allows logout URLs like:
// /auth/logout?returnTo=https://localhost:3000/en/dashboard
// /auth/logout?returnTo=https://localhost:3000/*/about
Note
When using "v2" strategy, make sure your logout URLs are registered in your Auth0 application's Allowed Logout URLs settings. The v2 endpoint supports wildcards in URLs.
Federated logout
By default, the logout endpoint only logs the user out from Auth0's session. To also log the user out from their identity provider (such as Google, Facebook, or SAML IdP), you can use the federated parameter:
<!-- Regular logout (Auth0 session only) -->
<a href="/auth/logout">Logout</a>
<!-- Federated logout (Auth0 + Identity Provider) -->
<a href="/auth/logout?federated">Logout from IdP</a>
<!-- Federated logout with custom returnTo -->
<a href="/auth/logout?federated&returnTo=https://example.com/goodbye"
>Logout from IdP</a
>
The federated parameter works with all logout strategies (auto, oidc, and v2) and is passed through to the appropriate Auth0 logout endpoint:
- OIDC logout:
https://your-domain.auth0.com/oidc/logout?federated&... - V2 logout:
https://your-domain.auth0.com/v2/logout?federated&...
OIDC logout privacy configuration
The SDK provides control over whether to include the id_token_hint parameter in OIDC logout URLs through the includeIdTokenHintInOIDCLogoutUrl configuration option. This setting allows you to balance security and privacy based on your application's requirements.
Default behavior (recommended)
By default, the SDK includes id_token_hint in OIDC logout URLs for enhanced security:
export const auth0 = new Auth0Client({
logoutStrategy: "auto", // or "oidc"
includeIdTokenHintInOIDCLogoutUrl: true // default value
// ... other config
});
Privacy-focused configuration
The default approach might include user information (PII) encoded in the ID token within logout URLs.
PII may appear in server logs, browser history, and referrer headers.
For applications where this is not acceptable, you can exclude id_token_hint from logout URLs:
export const auth0 = new Auth0Client({
logoutStrategy: "auto", // or "oidc"
includeIdTokenHintInOIDCLogoutUrl: false // exclude id_token_hint for privacy
// ... other config
});
This will still send the logout_hint and client_id parameters.
This flag is only effective with the oidc or auto (uses oidc when possible) logout strategy.
This has no effect with v2 strategy (v2 doesn't use id_token_hint).
Warning
When includeIdTokenHintInOIDCLogoutUrl: false, logout requests lose cryptographic verification. The OpenID Connect specification warns that "logout requests without a valid id_token_hint value are a potential means of denial of service." Use this setting only when privacy requirements outweigh DoS protection concerns.
Accessing the authenticated user
In the browser
To access the currently authenticated user on the client, you can use the useUser() hook, like so:
"use client";
import { useUser } from "@auth0/nextjs-auth0";
export default function Profile() {
const { user, isLoading, error } = useUser();
if (isLoading) return <div>Loading...</div>;
return (
<main>
<h1>Profile</h1>
<div>
<pre>{JSON.stringify(user, null, 2)}</pre>
</div>
</main>
);
}
Understanding useUser() Behavior
The useUser() hook uses SWR (Stale-While-Revalidate) under the hood, which provides smart caching and revalidation behavior. By default:
- Event-driven revalidation: Data automatically revalidates when you focus the browser tab, reconnect to the internet, or mount the component
- No background polling: The hook does not make continuous background requests unless explicitly configured
- Cache-first approach: Returns cached data immediately, then revalidates if needed
On the server (App Router)
On the server, the getSession() helper can be used in Server Components, Server Routes, and Server Actions to get the session of the currently authenticated user and to protect resources, like so:
Note
The getSession() method returns a complete session object containing the user profile and all available tokens (access token, ID token, and refresh token when present). Use this method for applications that only need user identity information without calling external APIs, as it provides access to the user's profile data from the ID token without requiring additional API calls. This approach is suitable for session-only authentication patterns.
For API access, use getAccessToken() to get an access token, this handles automatic token refresh.
import { auth0 } from "@/lib/auth0";
export default async function Home() {
const session = await auth0.getSession();
if (!session) {
return <div>Not authenticated</div>;
}
return (
<main>
<h1>Welcome, {session.user.name}!</h1>
</main>
);
}
On the server (Pages Router)
On the server, the getSession(req) helper can be used in getServerSideProps and API routes to get the session of the currently authenticated user and to protect resources, like so:
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { auth0 } from "@/lib/auth0";
export const getServerSideProps = (async (ctx) => {
const session = await auth0.getSession(ctx.req);
if (!session) return { props: { user: null } };
return { props: { user: session.user ?? null } };
}) satisfies GetServerSideProps<{ user: any | null }>;
export default function Page({
user
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (!user) {
return (
<main>
<p>Not authenticated!</p>
</main>
);
}
return (
<main>
<p>Welcome, {user.name}!</p>
</main>
);
}
Middleware
In middleware, the getSession(req) helper can be used to get the session of the currently authenticated user and to protect resources, like so:
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
// preserve the URL the user was trying to reach so they land back there after login
// returnTo must be a relative path — absolute external URLs are rejected to prevent open redirects
const loginUrl = new URL("/auth/login", request.nextUrl.origin);
loginUrl.searchParams.set(
"returnTo",
request.nextUrl.pathname + request.nextUrl.search
);
return NextResponse.redirect(loginUrl);
}
// the headers from the auth middleware should always be returned
return authRes;
}
Important
The request object must be passed as a parameter to the getSession(request) method when called from a middleware to ensure that any updates to the session can be read within the same request.
Protecting a Server-Side Rendered (SSR) Page
Page Router
Requests to /pages/profile without a valid session cookie will be redirected to the login page.
// pages/profile.js
import { auth0 } from "@/lib/auth0";
export default function Profile({ user }) {
return <div>Hello {user.name}</div>;
}
// withPageAuthRequired automatically uses ctx.resolvedUrl as returnTo, so the user
// is redirected back to the exact page (including query string) they tried to visit.
// You can optionally pass your own `getServerSideProps` function into
// `withPageAuthRequired` and the props will be merged with the `user` prop
export const getServerSideProps = auth0.withPageAuthRequired();
App Router
Requests to /profile without a valid session cookie will be redirected to the login page.
// app/profile/page.js
import { auth0 } from "@/lib/auth0";
export default auth0.withPageAuthRequired(
async function Profile() {
const { user } = await auth0.getSession();
return <div>Hello {user.name}</div>;
},
{ returnTo: "/profile" }
);
// returnTo is required for App Router — Server Components don't know their own URL.
// For dynamic routes, pass a function: returnTo({ params }) { return `/profile/${params.id}`; }
Protecting a Client-Side Rendered (CSR) Page
To protect a Client-Side Rendered (CSR) page, you can use the withPageAuthRequired higher-order function. Requests to /profile without a valid session cookie will be redirected to the login page.
// app/profile/page.tsx
"use client";
import { withPageAuthRequired } from "@auth0/nextjs-auth0";
export default withPageAuthRequired(function Page({ user }) {
return <div>Hello, {user.name}!</div>;
});
Protect an API Route
Page Router
Requests to /api/protected without a valid session cookie will fail with 401.
// pages/api/protected.js
import { auth0 } from "@/lib/auth0";
export default auth0.withApiAuthRequired(async function myApiRoute(req, res) {
const { user } = await auth0.getSession(req);
res.json({ protected: "My Secret", id: user.sub });
});
Then you can access your API from the frontend with a valid session cookie.
// pages/products
import { withPageAuthRequired } from "@auth0/nextjs-auth0";
import useSWR from "swr";
const fetcher = async (uri) => {
const response = await fetch(uri);
return response.json();
};
export default withPageAuthRequired(function Products() {
const { data, error } = useSWR("/api/protected", fetcher);
if (error) return <div>oops... {error.message}</div>;
if (data === undefined) return <div>Loading...</div>;
return <div>{data.protected}</div>;
});
App Router
Requests to /api/protected without a valid session cookie will fail with 401.
// app/api/protected/route.js
import { auth0 } from "@/lib/auth0";
export const GET = auth0.withApiAuthRequired(async function myApiRoute(req) {
const res = new NextResponse();
const { user } = await auth0.getSession(req);
return NextResponse.json({ protected: "My Secret", id: user.sub }, res);
});
Then you can access your API from the frontend with a valid session cookie.
// app/products/page.jsx
"use client";
import { withPageAuthRequired } from "@auth0/nextjs-auth0";
import useSWR from "swr";
const fetcher = async (uri) => {
const response = await fetch(uri);
return response.json();
};
export default withPageAuthRequired(function Products() {
const { data, error } = useSWR("/api/protected", fetcher);
if (error) return <div>oops... {error.message}</div>;
if (data === undefined) return <div>Loading...</div>;
return <div>{data.protected}</div>;
});
Protecting Server Actions
Next.js Server Actions are invoked via an internal POST request with a Next-Action header. This means they pass through your middleware just like any other request — and this creates a subtle but important pitfall when protecting them against expired sessions.
Why NextResponse.redirect() in middleware does not work for Server Actions
When a regular page navigation hits an expired session, returning a 303 redirect from middleware works perfectly: the browser follows the redirect and navigates to the login page.
However, when a Server Action is invoked, React's internal fetch runner sends a POST with a Next-Action header. The browser's fetch API automatically follows any 3xx redirect at the network layer — before React ever sees the response. The redirect is consumed silently: fetch sends a GET to /auth/login, receives back the login page HTML, and hands that HTML to the React runtime as the "result" of the server action. React discards it. The action appears to do nothing and the user stays on the same page with no feedback.
What you see in the browser network tab is:
POST /protected-page→303GET /auth/login→200- (no navigation)
The correct pattern
The fix requires two things working together.
Step 1 — Let the Server Action request through middleware.
Detect the Next-Action header and skip the redirect for server action requests. Return authResponse (the response from auth0.middleware()) so that session cookies and other headers set by the SDK are preserved, and the request proceeds to your server action.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function middleware(request: NextRequest) {
const authResponse = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authResponse;
}
const session = await auth0.getSession(request);
const isProtectedRoute = !request.nextUrl.pathname.startsWith("/public");
if (!session && isProtectedRoute) {
// Server Actions are POST requests with a Next-Action header.
// Returning a 303 redirect here does NOT navigate the browser — React's
// internal fetch runner follows the redirect silently and discards the
// login page HTML as a server action result. Let the request through so
// the redirect() call inside the server action can fire instead.
if (request.headers.has("next-action")) {
return authResponse;
}
return NextResponse.redirect(new URL("/auth/login", request.url), {
status: 303,
});
}
return authResponse;
}
Step 2 — Call redirect() from next/navigation inside the Server Action.
redirect() from next/navigation does not issue an HTTP redirect. It throws a special internal NEXT_REDIRECT error that Next.js encodes into the React Flight response. The client-side React runtime reads the redirect instruction and calls router.push() — this is the mechanism that actually navigates the browser.
// app/actions.ts
"use server";
import { redirect } from "next/navigation";
import { auth0 } from "@/lib/auth0";
export async function myProtectedAction(formData: FormData) {
// Check session BEFORE any try/catch block (see warning below).
const session = await auth0.getSession();
if (!session) {
redirect("/auth/login");
}
// ... rest of your action
}
Warning
Do not call redirect() inside a try/catch block.
redirect() works by throwing a special NEXT_REDIRECT error internally. If it is called inside a try/catch, that error will be caught and swallowed — the redirect will silently fail and the user will not be navigated. Always check the session and call redirect() before entering any try/catch block.
// ❌ Wrong — redirect() is called inside catch and will be swallowed
export async function myAction() {
try {
const session = await auth0.getSession();
if (!session) throw new Error("No session");
// ...
} catch (error) {
redirect("/auth/login"); // never fires — caught then discarded
}
}
// ✅ Correct — session check is outside try/catch
export async function myAction() {
const session = await auth0.getSession();
if (!session) {
redirect("/auth/login"); // fires correctly
}
try {
// ... rest of action
} catch (error) {
return { error: "Something went wrong" };
}
}
Why this is not an SDK bug
The Auth0 SDK does not issue 303 redirects for server action requests — that logic lives entirely in user-written middleware code. The SDK's auth0.middleware() only handles /auth/* routes and rolling session cookie refresh; it never redirects based on session state. The redirect in the buggy pattern is always user code.
The SDK's auth0.getSession() works correctly inside server actions. The issue is purely about how the redirect is triggered and which mechanism is capable of navigating the browser from within a server action context.
Accessing the idToken
idToken can be accessed from the session in the following way:
const session = await auth0.getSession();
const idToken = session.tokenSet.idToken;
Updating the session
The updateSession method could be used to update the session of the currently authenticated user in the App Router, Pages Router, and middleware. If the user does not have a session, an error will be thrown.
Note
Any updates to the session will be overwritten when the user re-authenticates and obtains a new session.
On the server (App Router)
On the server, the updateSession() helper can be used in Server Routes and Server Actions to update the session of the currently authenticated user, like so:
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function GET() {
const session = await auth0.getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await auth0.updateSession({
...session,
updatedAt: Date.now()
});
return NextResponse.json(null, { status: 200 });
}
Note
The updateSession() method is not usable in Server Components as it is not possible to write cookies.
On the server (Pages Router)
On the server, the updateSession(req, res, session) helper can be used in getServerSideProps and API routes to update the session of the currently authenticated user, like so:
import type { NextApiRequest, NextApiResponse } from "next";
import { auth0 } from "@/lib/auth0";
type ResponseData =
| {}
| {
error: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const session = await auth0.getSession(req);
if (!session) {
return res.status(401).json({ error: "Unauthorized" });
}
await auth0.updateSession(req, res, {
...session,
updatedAt: Date.now()
});
res.status(200).json({});
}
Middleware
In middleware, the updateSession(req, res, session) helper can be used to update the session of the currently authenticated user, like so:
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
await auth0.updateSession(request, authRes, {
...session,
user: {
...session.user,
// add custom user data
updatedAt: Date.now()
}
});
// the headers from the auth middleware should always be returned
return authRes;
}
Important
The request and response objects must be passed as a parameters to the updateSession(request, response, session) method when called from a middleware to ensure that any updates to the session can be read within the same request.
If you are using the Pages Router and need to read updates to the session made in the middleware within the same request, you will need to ensure that any updates to the session are propagated on the request object, like so:
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
await auth0.updateSession(request, authRes, {
...session,
user: {
...session.user,
// add custom user data
updatedAt: Date.now()
}
});
// create a new response with the updated request headers
const resWithCombinedHeaders = NextResponse.next({
request: {
headers: request.headers
}
});
// set the response headers (except set-cookie) from the auth response
authRes.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
resWithCombinedHeaders.headers.set(key, value);
}
});
// append the response headers (set-cookie) from the auth response
authRes.headers.getSetCookie().forEach(setCookie => {
resWithCombinedHeaders.headers.append('set-cookie', setCookie);
});
// the headers from the auth middleware should always be returned
return resWithCombinedHeaders;
}
Getting an access token
The getAccessToken() helper can be used both in the browser and on the server to obtain the access token to call external APIs. If the access token has expired and a refresh token is available, it will automatically be refreshed and persisted.
Important
Refresh Token Rotation: If your Auth0 application uses Refresh Token Rotation, configure an overlap period to prevent race conditions when multiple requests attempt to refresh tokens simultaneously. This can be configured in your Auth0 Dashboard under Applications > Advanced Settings > OAuth, or disable rotation entirely for server-side applications that don't require it.
In the browser
To obtain an access token to call an external API on the client, you can use the getAccessToken() helper, like so:
"use client";
import { getAccessToken } from "@auth0/nextjs-auth0";
export default function Component() {
async function fetchData() {
try {
const token = await getAccessToken();
// call external API with token...
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
}
return (
<main>
<button onClick={fetchData}>Fetch Data</button>
</main>
);
}
If you need the full response from /auth/access-token (for example, to access expires_in for client-side caching), pass includeFullResponse: true:
"use client";
import { getAccessToken } from "@auth0/nextjs-auth0";
export default function Component() {
async function fetchData() {
try {
const tokenSet = await getAccessToken({
includeFullResponse: true
});
// tokenSet.token, tokenSet.expires_in, tokenSet.expires_at, ...
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
}
return (
<main>
<button onClick={fetchData}>Fetch Data</button>
</main>
);
}
On the server (App Router)
On the server, the getAccessToken() helper can be used in Server Routes, Server Actions and Server Components to get an access token to call external APIs.
Important
Server Components cannot set cookies. Calling getAccessToken() in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted.
It is recommended to call getAccessToken(req, res) in the middleware if you need to use the refresh token in a Server Component as this will ensure the token is refreshed and correctly persisted.
For example:
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function GET() {
try {
const token = await auth0.getAccessToken();
// call external API with token...
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
return NextResponse.json({
message: "Success!"
});
}
On the server (Pages Router)
On the server, the getAccessToken(req, res) helper can be used in getServerSideProps and API routes to get an access token to call external APIs, like so:
import type { NextApiRequest, NextApiResponse } from "next";
import { auth0 } from "@/lib/auth0";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<{ message: string }>
) {
try {
const token = await auth0.getAccessToken(req, res);
// call external API with token...
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
res.status(200).json({ message: "Success!" });
}
Middleware
In middleware, the getAccessToken(req, res) helper can be used to get an access token to call external APIs, like so:
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
const accessToken = await auth0.getAccessToken(request, authRes);
// the headers from the auth middleware should always be returned
return authRes;
}
Important
The request and response objects must be passed as a parameters to the getAccessToken(request, response) method when called from a middleware to ensure that the refreshed access token can be accessed within the same request.
If you are using the Pages Router and are calling the getAccessToken method in both the middleware and an API Route or getServerSideProps, it's recommended to propagate the headers from the middleware, as shown below. This will ensure that calling getAccessToken in the API Route or getServerSideProps will not result in the access token being refreshed again.
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
const accessToken = await auth0.getAccessToken(request, authRes);
// create a new response with the updated request headers
const resWithCombinedHeaders = NextResponse.next({
request: {
headers: request.headers
}
});
// set the response headers (except set-cookie) from the auth response
authRes.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
resWithCombinedHeaders.headers.set(key, value);
}
});
// append the response headers (set-cookie) from the auth response
authRes.headers.getSetCookie().forEach(setCookie => {
resWithCombinedHeaders.headers.append('set-cookie', setCookie);
});
// the headers from the auth middleware should always be returned
return resWithCombinedHeaders;
}
Forcing Access Token Refresh
In some scenarios, you might need to explicitly force the refresh of an access token, even if it hasn't expired yet. This can be useful if, for example, the user's permissions or scopes have changed and you need to ensure the application has the latest token reflecting these changes.
The getAccessToken method provides an option to force this refresh.
App Router (Server Components, Route Handlers, Server Actions):
When calling getAccessToken without request and response objects, you can pass an options object as the first argument. Set the refresh property to true to force a token refresh.
// app/api/my-api/route.ts
import { auth0 } from "@/lib/auth0";
export async function GET() {
try {
// Force a refresh of the access token
const { token, expiresAt, scope } = await auth0.getAccessToken({
refresh: true
});
// Use the refreshed token
// ...
} catch (error) {
console.error("Error getting access token:", error);
return Response.json(
{ error: "Failed to get access token" },
{ status: 500 }
);
}
}
App Router Route Handlers — Refresh + Custom Response Headers/Cookies:
If your Route Handler needs to both refresh the session and return a NextResponse you fully control (e.g., to set additional cookies with a Domain or SameSite attribute), use the explicit getAccessToken(req, res, options) signature. This writes the refreshed session directly onto the NextResponse you pass, so all Set-Cookie headers — session and custom — are consolidated on the one response object you return.
// app/api/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function POST(req: NextRequest) {
// 1. Create the response object you will return.
const res = new NextResponse();
// 2. Pass req + res explicitly so the SDK writes the refreshed session
// cookies directly onto `res` rather than into Next.js's internal
// AsyncLocalStorage store. This makes the Set-Cookie headers (including
// Domain, SameSite, Secure, etc. from your session.cookie config)
// available on the response object you control.
const { token } = await auth0.getAccessToken(req, res, { refresh: true });
// 3. Set any additional cookies on the same response object.
res.cookies.set("my-cookie", "value", {
domain: ".example.com",
secure: true,
sameSite: "lax"
});
// 4. Return the single response — it now carries both the refreshed
// session Set-Cookie headers and your custom cookie.
return res;
}
Important
Calling getAccessToken({ refresh: true }) (without req/res) in a Route Handler writes the refreshed session through Next.js's internal cookie store, not onto a NextResponse you construct. If you then build a new NextResponse() and add cookies to it, that response will be missing the refreshed session cookies. Always pass req and res explicitly when you need all cookies on the same response object.
Pages Router (getServerSideProps, API Routes):
When calling getAccessToken with request and response objects (from getServerSideProps context or an API route), the options object is passed as the third argument.
// pages/api/my-pages-api.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
export default withApiAuthRequired(async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
// Force a refresh of the access token
const { token, expiresAt, scope } = await getAccessToken(req, res, {
refresh: true
});
// Use the refreshed token
// ...
} catch (error: any) {
console.error("Error getting access token:", error);
res.status(error.status || 500).json({ error: error.message });
}
});
By setting { refresh: true }, you instruct the SDK to bypass the standard expiration check and request a new access token from the identity provider using the refresh token (if available and valid). The new token set (including the potentially updated access token, refresh token, and expiration time) will be saved back into the session automatically.
This will in turn, update the access_token, id_token and expires_at fields of tokenset in the session.
Tip
To refresh access tokens slightly before they expire across all getAccessToken() calls, set tokenRefreshBuffer on the Auth0Client:
export const auth0 = new Auth0Client({
tokenRefreshBuffer: 60, // refresh tokens up to 60s before expiry
// ... other config
});
Optimizing Token Refresh in Middleware
When using getAccessToken() in middleware for Backend-for-Frontend (BFF) patterns or to ensure fresh tokens for Server Components, avoid calling it on every request. Instead, implement time-based refresh logic to only refresh when the token is nearing expiration.
Note
This pattern is designed for centralized token management in middleware. For per-request latency mitigation (e.g., checking token expiry immediately before a critical API call), see Mitigating Token Expiration Race Conditions.
Why This Matters
Calling getAccessToken() on every request can:
- Increase latency by 50-200ms per request
- Generate unnecessary load on Auth0's token endpoint
- Risk hitting rate limits at scale
- Waste computational resources
Recommended Pattern
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "./lib/auth0";
// Define your refresh threshold (in seconds before expiry)
const TOKEN_REFRESH_THRESHOLD = 5 * 60; // 5 minutes
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
// Only refresh if token is expiring soon
if (session.tokenSet?.expiresAt) {
const expiresInSeconds = session.tokenSet.expiresAt - Date.now() / 1000;
if (expiresInSeconds < TOKEN_REFRESH_THRESHOLD) {
try {
await auth0.getAccessToken(request, authRes, { refresh: true });
// Token refreshed and persisted via authRes
} catch (error) {
console.error("Token refresh failed:", error);
return NextResponse.redirect(
new URL("/auth/logout", request.nextUrl.origin)
);
}
}
}
return authRes;
}
export const config = {
matcher: [
// Apply to protected routes only
"/dashboard/:path*",
"/api/:path*"
]
};
Warning
Server Components cannot persist token updates. Always refresh tokens in middleware (where cookies can be set) rather than in Server Components to ensure refreshed tokens are saved to the session.
Multi-Resource Refresh Tokens (MRRT)
Multi-Resource Refresh Tokens allow using a single refresh token to obtain access tokens for multiple audiences, simplifying token management in applications that interact with multiple backend services.
Read more about Multi-Resource Refresh Tokens in the Auth0 documentation.
Warning
When using Multi-Resource Refresh Token Configuration (MRRT), Refresh Token Policies on your Application need to be configured with the audiences you want to support. See the Auth0 MRRT documentation for setup instructions.
Tokens requested for audiences outside your configured policies will be ignored by Auth0, which will return a token for the default audience instead!
Basic Configuration
Configure a default audience in your Auth0 client initialization:
// lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
authorizationParameters: {
audience: "https://api.example.com", // Your default audience
scope: "openid profile email offline_access read:products read:orders"
}
});
Configuring Scopes Per Audience
When working with multiple APIs, you can define different default scopes for each audience by passing an object instead of a string. This is particularly useful when different APIs require different default scopes:
// lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
authorizationParameters: {
audience: "https://api.example.com", // Default audience
scope: {
"https://api.example.com":
"openid profile email offline_access read:products read:orders",
"https://analytics.example.com":
"openid profile email offline_access read:analytics write:analytics",
"https://admin.example.com":
"openid profile email offline_access read:admin write:admin delete:admin"
}
}
});
How it works:
- Each key in the
scopeobject is anaudienceidentifier - The corresponding value is the scope string for that audience
- When calling
getAccessToken({ audience: "..." }), the SDK automatically uses the configured scopes for that audience. When scopes are also passed in the method call, they are be merged with the default scopes for that audience.
Note
When using scope as an object, and no entry for the default audience is provided, the SDK defaults to DEFAULT_SCOPE (only for the default audience). This is the default audience used during authentication and determines which scope from the map is used for the initial login.
Usage Example
To retrieve access tokens for different audiences, use the getAccessToken() method with an audience (and optionally also the scope) parameter. Here's an example for an API Route:
// app/api/data/route.ts
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function GET() {
try {
// Get token for default audience
const defaultToken = await auth0.getAccessToken();
// Get token for different audience
const dataToken = await auth0.getAccessToken({
audience: "https://data-api.example.com"
});
// Get token with additional scopes
const adminToken = await auth0.getAccessToken({
audience: "https://admin.example.com",
scope: "write:admin"
});
// Call external API with token
const response = await fetch("https://data-api.example.com/data", {
headers: { Authorization: `Bearer ${dataToken.token}` }
});
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch data" },
{ status: 500 }
);
}
}
Note
The syntax for calling getAccessToken() may vary slightly depending on where it's being used. See the Getting an access token section for specific syntax examples for App Router (Server Components, API Routes, Server Actions), Pages Router (API Routes, getServerSideProps), Middleware, and client-side usage.
Token Management Best Practices
Configure Broad Default Scopes: Define comprehensive scopes in your Auth0Client constructor for common use cases. This minimizes the need to request additional scopes dynamically, reducing the amount of tokens that need to be stored.
export const auth0 = new Auth0Client({
authorizationParameters: {
audience: "https://api.example.com",
// Configure broad default scopes for most common operations
scope:
"openid profile email offline_access read:products read:orders read:users"
}
});
Minimize Dynamic Scope Requests: Avoid passing scope when calling getAccessToken() unless absolutely necessary. Each audience + scope combination results in a token to store in the session, increasing session size.
// Preferred: Use default scopes
const token = await auth0.getAccessToken({
audience: "https://api.example.com"
});
// Avoid unless necessary: Dynamic scopes increase session size
const token = await auth0.getAccessToken({
audience: "https://api.example.com",
scope: "openid profile email read:products write:products admin:all"
});
Consider Stateful Session Storage: If your application requires strict least privilege with many dynamic scope requests, we recommend to use a stateful session storage instead of cookie-based to avoid session size limitations.
Mitigating Token Expiration Race Conditions in Latency-Sensitive Operations
For applications where an API call might be made very close to the token's expiration time, network latency can cause the token to expire before the API receives it. To prevent this race condition, you can implement a strategy to refresh the token proactively when it's within a certain buffer period of its expiration.
Note
This pattern is designed for per-request latency mitigation immediately before critical API calls. For centralized token management in middleware that benefits all Server Components, see Optimizing Token Refresh in Middleware.
The general approach is as follows:
- Before making a sensitive API call, get the session and check the
expiresAttimestamp from thetokenSet. - Determine if the token is within your desired buffer period of expiring.
- If it is, force a token refresh by calling
auth0.getAccessToken({ refresh: true }). - Use the newly acquired access token for your API call.
Example Implementation:
// app/api/critical-operation/route.ts
import { auth0 } from "@/lib/auth0";
import { NextResponse } from "next/server";
// Define your latency buffer (in seconds before expiry)
const LATENCY_BUFFER = 30; // 30 seconds
export async function POST() {
const session = await auth0.getSession();
if (!session?.tokenSet?.expiresAt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const expiresInSeconds = session.tokenSet.expiresAt - Date.now() / 1000;
let token = session.tokenSet.accessToken;
// Refresh if token expires within the latency buffer
if (expiresInSeconds < LATENCY_BUFFER) {
const refreshed = await auth0.getAccessToken({ refresh: true });
token = refreshed.token;
}
// Make critical API call with fresh token
const response = await fetch("https://api.example.com/critical", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ /* ... */ })
});
return NextResponse.json(await response.json());
}
Buffer Configuration:
Adjust the buffer based on your API's typical response time and network conditions:
// Short API calls with fast network
const LATENCY_BUFFER = 15; // 15 seconds
// Standard configuration
const LATENCY_BUFFER = 30; // 30 seconds
// Slow APIs or unreliable network
const LATENCY_BUFFER = 90; // 90 seconds
This ensures that the token you send is guaranteed to be valid for at least the duration of the buffer, accounting for potential network delays.
Important
This strategy is not a solution for long-running operations that take longer than the token's total validity period (e.g., 10 minutes). In those cases, the token will still expire mid-operation. The correct approach for long-running tasks is to call getAccessToken() immediately before the operation that requires it, ensuring you have a fresh token. The buffer is only for mitigating latency-related failures in short-lived requests.
Revoking tokens
revokeRefreshToken() revokes the refresh token stored in the current session at Auth0's /oauth/revoke endpoint (RFC 7009). Auth0 will also invalidate all access tokens issued under the same authorization grant — any subsequent API calls using those tokens will fail. Client authentication (clientSecret, Private Key JWT, or mTLS) is applied automatically from your Auth0Client configuration.
Note
Logging out revokes the session's refresh token automatically — auth0.middleware / the /auth/logout route revokes before clearing the session. revokeRefreshToken() is for explicit revocation outside the logout flow, for example revoking a user's access without redirecting them.
revokeRefreshToken() reads the refresh token from the current session and revokes it. The local session cookie is not cleared — call handleLogout for a full logout that also clears the session.
App Router (Server Actions, Route Handlers):
// app/api/revoke/route.ts
import { auth0 } from "@/lib/auth0";
export async function POST() {
try {
await auth0.revokeRefreshToken();
return Response.json({ revoked: true });
} catch (error) {
console.error("Failed to revoke refresh token:", error);
return Response.json({ error: "Failed to revoke" }, { status: 500 });
}
}
Pages Router: pass the incoming request via options.req:
// pages/api/revoke.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { auth0 } from "@/lib/auth0";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
await auth0.revokeRefreshToken({ req });
res.status(200).json({ revoked: true });
} catch (error) {
console.error("Failed to revoke refresh token:", error);
res.status(500).json({ error: "Failed to revoke" });
}
}
If there is no active session or the session has no refresh token, a TokenRevocationError is thrown.
Multi-Factor Authentication (MFA)
Step-up Authentication
Step-up authentication is a pattern where an application allows access to some resources with potential sensitive data, but requires the user to authenticate with a stronger mechanism (like MFA) to access others.
The SDK supports handling the mfa_required error from Auth0 when an API requires higher security. This typically happens when you use an Auth0 Action or Rule to enforce MFA for specific audiences or scopes.
Handling MfaRequiredError
When you request an Access Token for a resource that requires MFA, Auth0 will return a 403 Forbidden with an mfa_required error code. The SDK automatically catches this and bubbles it up as an MfaRequiredError, containing the mfa_token needed to resolve the challenge.
You should catch this error in your API routes or Server Actions and forward the mfa_token to your client.
Server Side (API Route):
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
import { MfaRequiredError } from "@auth0/nextjs-auth0/server";
export async function GET() {
try {
const { token } = await auth0.getAccessToken({
audience: "https://my-high-security-api",
refresh: true // Ensure we get a fresh token check
});
return NextResponse.json({ token });
} catch (error) {
if (error instanceof MfaRequiredError) {
// Forward the error details to the client
return NextResponse.json(error.toJSON(), { status: 403 });
}
throw error;
}
}
Client Side:
When the client receives the 403 with mfa_required, you can either redirect the user to a dedicated MFA page or use the popup-based approach to complete MFA without a full-page redirect.
Option 1: Full-page redirect
const response = await fetch("/api/protected");
if (response.status === 403) {
const data = await response.json();
if (data.error === "mfa_required") {
// Redirect to your MFA page or show MFA prompt
// Pass the mfa_token to the challenge flow
window.location.href = `/mfa-challenge?token=${data.mfa_token}`;
}
}
Option 2: Popup (no redirect)
Use mfa.challengeWithPopup() to complete MFA in a popup without leaving the current page. See Reactive MFA Step-Up (Popup) for full documentation.
MFA Tenant Configuration
The SDK relies on background token refreshes to maintain user sessions. For these non-interactive requests to succeed, it is important to configure your MFA policies to allow refresh_token exchanges without immediate user challenge.
Enforcing "Always" or "All Applications" in your global Tenant MFA Policy will block these background requests, as they cannot satisfy an interactive MFA challenge.
Recommended Configuration:
- Set Tenant MFA Policy to "Adaptive" or "Never".
- Use Auth0 Actions to enforce MFA conditionally (only when specific resources are requested).
Example Action Code:
exports.onExecutePostLogin = async (event, api) => {
const grantType = event.request?.body?.grant_type;
if (grantType === 'refresh_token') {
// Check if user has enrolled factors
const enrolledFactors = event.user.multifactor || [];
if (enrolledFactors.length > 0) {
// Challenge with all available factor types
// This returns mfa_required error during token endpoint
api.authentication.challengeWithAny([
{ type: 'otp' },
{ type: 'phone' },
{ type: 'email' },
{ type: 'push-notification' },
{ type: 'recovery-code' }
]);
} else {
// Prompt enrollment (also returns mfa_required error)
api.authentication.enrollWithAny([
{ type: 'otp' },
{ type: 'phone' },
{ type: 'email' },
{ type: 'push-notification' }
]);
}
} else {
console.log('[MFA Action] Skipping: not refresh_token grant or audience not protected');
}
};
For more information on how to customize MFA flows using post-login Actions, take a look at this auth0 docs page.
MFA Error Types
| Error Class | Code | When Thrown |
|---|---|---|
MfaRequiredError | mfa_required | Token refresh requires MFA step-up |
MfaTokenNotFoundError | mfa_token_not_found | No MFA context for provided token |
MfaTokenExpiredError | mfa_token_expired | Encrypted MFA token TTL exceeded |
MfaTokenInvalidError | mfa_token_invalid | Token tampered or wrong secret |
Configuration
Configure MFA token TTL via options or environment variable:
// Option 1: Via constructor
const auth0 = new Auth0Client({
mfaContextTtl: 600 // 10 minutes in seconds
});
# Option 2: Via environment variable
AUTH0_MFA_CONTEXT_TTL=600
Default TTL is 300 seconds (5 minutes), matching Auth0's mfa_token expiration.
Session Context
When MFA is required, the SDK automatically stores MFA context in the session keyed by a hash of the raw token.
Note
The MFA context is cleaned up automatically when the session is written. Expired contexts (based on mfaContextTtl) are removed to prevent session bloat.
Passwordless Authentication
Auth0 supports passwordless authentication via one-time passwords (OTPs) or magic links delivered by email or SMS. No password is required — the user proves identity by possessing the inbox or phone.
The SDK exposes:
- Built-in route handlers served via
auth0.handler(catch-all route) orauth0.middleware(proxy pattern) that handle the full OTP lifecycle server-side auth0.passwordless— a server-side client for calling start/verify from Server Actions or API routes directlypasswordlessfrom@auth0/nextjs-auth0/client— a thin client-side singleton that calls the route handlers
Universal Login (Recommended)
The simplest approach: pass the connection parameter to the standard login redirect. Auth0's Universal Login handles OTP delivery, input, and verification entirely on its hosted page — your app needs no custom form or error handling.
{/* Works with any passwordless connection enabled on your application */}
<a href="/auth/login?connection=email">Sign in with email</a>
<a href="/auth/login?connection=sms">Sign in via SMS</a>
After the user completes the flow, Auth0 redirects to /auth/callback and the SDK creates a session — identical to a standard OAuth callback. No additional route handler setup is needed beyond the standard auth0.handler mount.
Note
The rest of this section documents the custom (headless) API — auth0.passwordless and the passwordless client singleton — for cases where you need full control over the login UI.
Auth0 Setup
Before using passwordless, enable a Passwordless connection in the Auth0 Dashboard:
- Go to Authentication > Passwordless and enable Email and/or SMS.
- Under Applications, enable the connection for your application.
- Ensure the application's Grant Types include the passwordless OTP grant (Auth0 enables this automatically for passwordless connections).
- Magic link only — Set the
allow_magiclink_verify_without_sessionflag via the Management API. This is required so Auth0 does not demand its own session cookie when the user clicks the link.
Route Handler Setup
The SDK registers two handlers automatically when you mount auth0.handler:
| Method | Default Path | Purpose |
|---|---|---|
POST | /auth/passwordless/start | Send OTP to user's email or phone |
POST | /auth/passwordless/verify | Verify OTP and create a session |
App Router — add POST to your existing catch-all auth route:
// app/auth/[auth0]/route.ts
import { auth0 } from "@/lib/auth0";
export const GET = auth0.handler;
export const POST = auth0.handler;
Pages Router — mount the handler in your API route:
// pages/api/auth/[auth0].ts
import { auth0 } from "@/lib/auth0";
export default auth0.handler;
Client-Side Usage
Import the passwordless singleton from @auth0/nextjs-auth0/client. Call start() to send the OTP, then verify() after the user submits the code — the session cookie is set automatically.
Email OTP flow:
"use client";
import { useState } from "react";
import { passwordless } from "@auth0/nextjs-auth0/client";
import { PasswordlessStartError, PasswordlessVerifyError } from "@auth0/nextjs-auth0/errors";
export function EmailOtpForm() {
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"start" | "verify">("start");
const [error, setError] = useState<string | null>(null);
async function handleStart(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
await passwordless.start({ connection: "email", email, send: "code" });
setStep("verify");
} catch (err) {
if (err instanceof PasswordlessStartError) {
setError(err.error_description);
}
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
await passwordless.verify({ connection: "email", email, verificationCode: code });
window.location.href = "/dashboard";
} catch (err) {
if (err instanceof PasswordlessVerifyError) {
setError(err.error === "invalid_grant" ? "Invalid or expired code." : err.error_description);
}
}
}
if (step === "verify") {
return (
<form onSubmit={handleVerify}>
{error && <p>{error}</p>}
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Enter code" />
<button type="submit">Sign in</button>
</form>
);
}
return (
<form onSubmit={handleStart}>
{error && <p>{error}</p>}
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="Email" />
<button type="submit">Send code</button>
</form>
);
}
SMS OTP flow:
// Send OTP to phone number (E.164 format)
await passwordless.start({ connection: "sms", phoneNumber: "+14155550100" });
// Verify after user submits the code
await passwordless.verify({ connection: "sms", phoneNumber: "+14155550100", verificationCode: "123456" });
window.location.href = "/dashboard";
Magic link (email only):
The magic link flow uses the standard OAuth authorization code grant. When the user clicks the emailed link, Auth0 redirects to /auth/callback with a code and state — the same callback used for regular logins. The SDK saves a transaction cookie during start() so the callback can complete the code exchange and create a session.
Important
Magic links require the allow_magiclink_verify_without_session tenant-level setting to be enabled. Without it, Auth0 requires its own session cookie (nstate) to be present when the user clicks the link — but that cookie lives on the Auth0 domain and cannot be set from your application. Enable it in Auth0 Dashboard → Settings → Advanced → Allow Magic Link Verify Without Session, or via the Management API:
PATCH /api/v2/tenants/settings
{ "flags": { "allow_magiclink_verify_without_session": true } }
// Step 1 — send the magic link (no verify step needed)
await passwordless.start({ connection: "email", email: "user@example.com", send: "link" });
// → User receives an email with a link like:
// https://<domain>/passwordless/verify_redirect?...&redirect_uri=https://yourapp.com/auth/callback&state=...
When the user clicks the link, Auth0 redirects to your /auth/callback route with ?code=...&state=.... The SDK resolves the transaction by matching state to the cookie saved during start(), exchanges the code for tokens, and creates a session — identical to a standard OAuth callback. No second verify() call is required.
Server-Side (Headless) Usage
Use auth0.passwordless directly in Server Actions or API Routes when you want full control over the request/response cycle, for example to return a custom JSON body.
App Router — Server Action:
"use server";
import { auth0 } from "@/lib/auth0";
export async function sendOtp(email: string) {
await auth0.passwordless.start({ connection: "email", email, send: "code" });
}
export async function verifyOtp(email: string, verificationCode: string) {
// Verifies the OTP and sets the session cookie via next/headers
await auth0.passwordless.verify({ connection: "email", email, verificationCode });
}
Pages Router — API Route:
import { auth0 } from "@/lib/auth0";
import { NextRequest, NextResponse } from "next/server";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { email, verificationCode } = req.body;
// Wrap in NextRequest/NextResponse so the SDK can read/write cookies.
// Build the URL from the actual host + protocol so it works in non-local deployments.
const host = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host;
const proto = (Array.isArray(req.headers["x-forwarded-proto"])
? req.headers["x-forwarded-proto"][0]
: req.headers["x-forwarded-proto"]) ?? "https";
const nextReq = new NextRequest(
host ? new URL(req.url!, `${proto}://${host}`) : new URL(req.url!, "https://localhost"),
{ method: req.method, headers: req.headers as HeadersInit }
);
const nextRes = new NextResponse();
await auth0.passwordless.verify(nextReq, nextRes, {
connection: "email",
email,
verificationCode
});
const cookies = nextRes.headers.getSetCookie();
if (cookies.length > 0) {
res.setHeader("Set-Cookie", cookies);
}
res.status(200).json({ success: true });
}
Error Handling
Both PasswordlessStartError and PasswordlessVerifyError expose error (the OAuth error code) and error_description.
import { PasswordlessStartError, PasswordlessVerifyError } from "@auth0/nextjs-auth0/errors";
try {
await passwordless.start({ connection: "email", email, send: "code" });
} catch (err) {
if (err instanceof PasswordlessStartError) {
// err.error — e.g. 'invalid_request', 'too_many_requests'
// err.error_description — human-readable message
console.error(err.error, err.error_description);
}
}
try {
await passwordless.verify({ connection: "email", email, verificationCode: code });
} catch (err) {
if (err instanceof PasswordlessVerifyError) {
if (err.error === "invalid_grant") {
// Wrong or expired OTP
}
}
}
In server-side code the same error classes are thrown, so the same instanceof checks apply.
Note
When the built-in route handlers return an error response (e.g. 400 or 403), they serialize the error using error.toJSON() which matches the Auth0 API error shape { error, error_description }. The client-side passwordless singleton re-throws a typed error from that JSON automatically.
Route Configuration
The default route paths can be overridden with environment variables:
# .env.local
NEXT_PUBLIC_PASSWORDLESS_START_ROUTE=/auth/passwordless/start
NEXT_PUBLIC_PASSWORDLESS_VERIFY_ROUTE=/auth/passwordless/verify
Because both variables are prefixed with NEXT_PUBLIC_, they are inlined by the Next.js bundler and available on the client without an extra API call.
Error Types
| Error Class | error Code | When Thrown |
|---|---|---|
PasswordlessStartError | invalid_request | Missing or malformed body field |
PasswordlessStartError | too_many_requests | Auth0 rate limit on OTP delivery |
PasswordlessStartError | client_error | Network failure or unparseable response |
PasswordlessVerifyError | invalid_grant | Wrong or expired OTP |
PasswordlessVerifyError | client_error | Network failure or unparseable response |
Passwordless OTP on Database Connections
Note
This feature is currently in Early Access (EA). Contact your Auth0 account team to have it enabled on your tenant.
Auth0 supports passwordless OTP login against standard database connections (auth0 strategy) configured with email_otp or phone_otp. This is distinct from the legacy passwordless flow — it works with existing database connections that already hold user accounts, rather than requiring a dedicated email or SMS passwordless connection.
The flow is two steps: request a challenge (OTP delivery) via POST /otp/challenge, then exchange the returned auth_session + the OTP the user enters for tokens via POST /oauth/token.
How it differs from Passwordless Authentication
| Passwordless Authentication | Passwordless OTP on DB Connections | |
|---|---|---|
| Connection type | Dedicated email / sms passwordless connection | Any database connection with email_otp / phone_otp enabled |
| Challenge endpoint | POST /passwordless/start | POST /otp/challenge |
| Token exchange | realm + username params | auth_session + otp params |
| Magic link support | Yes | No |
| SDK methods | passwordless.start() / passwordless.verify() | passwordless.challengeWithEmail() / challengeWithPhoneNumber() / loginWithOtp() |
| Signup on first login | Automatic | Controlled by allowSignup option (default false) |
Auth0 Setup
1. Enable the Passwordless OTP feature
This feature is in Early Access. Contact your Auth0 account team to have it enabled on your tenant.
2. Enable OTP on your database connection
Enable Connection Attributes
- Go to Authentication → Database and select your connection.
- Select the Attributes section.
- Click Add attribute and create attributes for email and/or phone.
- Click Configure on each created attribute.
- Enable the Use as Identifier option.
- Check Allow signup if you want new users to be able to sign up via OTP.
Enable Connection Authentication Methods
- Still on your connection, select the Authentication Methods section.
- Click Configure against the Email or Phone option.
- Under OTP for login, select Allow.
- Save changes.
For phone OTP, attach an SMS provider under Branding → Phone Provider.
3. Application setup
- Go to Applications → your app → Settings.
- Add
http://localhost:3000/auth/callbackto Allowed Callback URLs andhttp://localhost:3000to Allowed Logout URLs. - Under Connections, confirm your database connection is enabled for this application.
Note
Phone OTP signup (allowSignup: true) is not supported in non-interactive flows — Auth0 requires phone_verified: true before account creation. Pre-create users who will sign in via phone OTP, or use email OTP for the signup path.
Route Handler Setup
The SDK registers two handlers automatically when you mount auth0.handler:
| Method | Default Path | Purpose |
|---|---|---|
POST | /auth/passwordless/otp/challenge | Request an OTP for a DB connection user |
POST | /auth/passwordless/otp/token | Exchange auth_session + OTP for tokens and set session cookie |
App Router — ensure POST is exported from your catch-all auth route:
// app/auth/[auth0]/route.ts
import { auth0 } from "@/lib/auth0";
export const GET = auth0.handler;
export const POST = auth0.handler;
Pages Router — ensure the API route handles all methods:
// pages/api/auth/[auth0].ts
import { auth0 } from "@/lib/auth0";
export default auth0.handler;
Client-Side Usage
Import the passwordless singleton from @auth0/nextjs-auth0/client. Call challengeWithEmail() or challengeWithPhoneNumber() to request an OTP, then loginWithOtp() after the user submits the code — the session cookie is set automatically on success.
import { passwordless } from "@auth0/nextjs-auth0/client";
// Step 1 — request OTP by email
const { authSession } = await passwordless.challengeWithEmail({
email: "user@example.com",
connection: "Username-Password-Authentication",
allowSignup: true, // set false to prevent new account creation
});
// Step 1 — request OTP by phone
const { authSession } = await passwordless.challengeWithPhoneNumber({
phoneNumber: "+14155550100",
connection: "Username-Password-Authentication",
deliveryMethod: "text", // "text" | "voice"
allowSignup: false, // phone signup not supported in non-interactive flows
});
// Step 2 — verify OTP and create session
await passwordless.loginWithOtp({
authSession, // opaque value returned by the challenge step — never log or persist
otp: "123456",
});
// session cookie is now set; redirect to dashboard
Important
The challenge endpoint always returns a valid-looking authSession regardless of whether the user exists (user-enumeration prevention). If allowSignup is false for a non-existent user, or the user is blocked, no OTP is delivered — the authSession is silently non-functional. Calling loginWithOtp with it will fail with invalid_request.
Full form example with error handling:
"use client";
import { useState } from "react";
import { passwordless } from "@auth0/nextjs-auth0/client";
import type { PasswordlessDbChallenge } from "@auth0/nextjs-auth0/types";
export function PasswordlessDbForm() {
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [challenge, setChallenge] = useState<PasswordlessDbChallenge | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleChallenge(e: React.FormEvent) {
e.preventDefault();
try {
const result = await passwordless.challengeWithEmail({
email,
connection: "Username-Password-Authentication",
allowSignup: true,
});
setChallenge(result);
} catch (err: unknown) {
const e = err as { error_description?: string };
setError(e.error_description ?? "Failed to send code.");
}
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
if (!challenge) return;
try {
await passwordless.loginWithOtp({ authSession: challenge.authSession, otp });
window.location.href = "/dashboard";
} catch (err: unknown) {
const e = err as { error?: string; error_description?: string };
setError(e.error_description ?? "Invalid or expired code.");
}
}
if (challenge) {
return (
<form onSubmit={handleLogin}>
<input value={otp} onChange={e => setOtp(e.target.value)} placeholder="123456" />
{error && <p>{error}</p>}
<button type="submit">Verify</button>
</form>
);
}
return (
<form onSubmit={handleChallenge}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
{error && <p>{error}</p>}
<button type="submit">Send code</button>
</form>
);
}
Note
If the user has MFA configured, loginWithOtp throws a raw mfa_required error with an encrypted mfa_token. Pass it to mfa.getAuthenticators() to continue — the full flow is identical to the MFA handling in Passwordless Authentication and Passkey Authentication. See the with-passwordless-db example for a working implementation.
Server-Side (Headless) Usage
Use auth0.passwordless directly in Server Actions or API Routes for full control:
"use server";
import { auth0 } from "@/lib/auth0";
// Step 1 — request OTP
export async function requestOtp(email: string) {
const challenge = await auth0.passwordless.challengeWithEmail({
email,
connection: "Username-Password-Authentication",
allowSignup: true,
});
return challenge; // { authSession: "..." }
}
// Step 2 — verify OTP and create session (App Router)
export async function verifyOtp(authSession: string, otp: string) {
// Sets the session cookie via next/headers; navigate the user after this resolves
await auth0.passwordless.loginWithOtp({ authSession, otp });
}
Pages Router — pass req and res to write the session cookie:
// pages/api/passwordless-db-login.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { auth0 } from "@/lib/auth0";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { authSession, otp } = req.body;
await auth0.passwordless.loginWithOtp(req, res, { authSession, otp });
res.status(200).json({ ok: true });
}
Route Configuration
The default route paths can be overridden with environment variables:
# .env.local
NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE=/auth/passwordless/otp/challenge
NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE=/auth/passwordless/otp/token
Because both variables are prefixed with NEXT_PUBLIC_, they are inlined by the Next.js bundler and available on the client without an extra API call.
Error Types
| Error Class | code | error value | When Thrown |
|---|---|---|---|
PasswordlessDbChallengeError | passwordless_challenge_error | invalid_connection | Connection does not have OTP enabled or is not a DB connection |
PasswordlessDbChallengeError | passwordless_challenge_error | invalid_request | Malformed request body |
PasswordlessDbGetTokenError | passwordless_login_error | invalid_request | Wrong OTP, expired auth_session, blocked user, or signup disabled for non-existent user |
PasswordlessDbGetTokenError | passwordless_login_error | invalid_grant | OTP has already been used or has expired |
Both error classes expose error and error_description fields matching the Auth0 API error shape and are importable from @auth0/nextjs-auth0/errors.
Passkey Authentication
Auth0 supports passkey (WebAuthn) authentication — users sign in with device biometrics or a PIN instead of a password. The private key never leaves the device's secure enclave.
The SDK exposes:
- Built-in route handlers served via
auth0.handler(catch-all route) orauth0.middleware(proxy pattern) that handle the full passkey lifecycle server-side auth0.passkey— a server-side client for calling challenge/verify from Server Actions or API routes directlypasskeyfrom@auth0/nextjs-auth0/client— a client-side singleton that drives the full WebAuthn ceremony and calls the route handlers
Universal Login (Recommended)
The simplest approach: redirect to Auth0's Universal Login page with passkey support enabled. Auth0 handles the entire WebAuthn ceremony on its hosted page — your app needs no custom form.
<a href="/auth/login">Sign in</a>
After the user completes the flow, Auth0 redirects to /auth/callback and the SDK creates a session — identical to a standard OAuth callback.
Note
The rest of this section documents the custom (headless) API — auth0.passkey and the passkey client singleton — for cases where you need full control over the login UI.
Auth0 Setup
Before using passkeys, enable passkey support in the Auth0 Dashboard:
- Go to Security → Attack Protection → Multi-factor Authentication and ensure your application is configured for passkeys.
- Go to Applications → <your app> → Settings and ensure:
- Allowed Callback URLs includes your app's callback URL
- Allowed Web Origins includes your app's origin (required for WebAuthn's
rpIdcheck)
- If using a custom domain, the passkey
rpIdis your custom domain. Ensure it is configured under Branding → Custom Domains.
Important
The /oauth/token passkey grant requires a JSON request body (Content-Type: application/json) because authn_response must be a nested object. The SDK handles this automatically.
Note
Default database connections require an email field in the signup options. Connections with custom attributes configuration may not require it — Auth0 will return an error if a required field is missing.
Route Handler Setup
The SDK registers three handlers automatically when you mount auth0.handler:
| Method | Default Path | Purpose |
|---|---|---|
POST | /auth/passkey/register | Get a WebAuthn credential creation challenge for a new user |
POST | /auth/passkey/challenge | Get a WebAuthn credential assertion challenge for an existing user |
POST | /auth/passkey/get-token | Verify the WebAuthn credential and create a session |
App Router — add POST to your existing catch-all auth route:
// app/auth/[auth0]/route.ts
import { auth0 } from "@/lib/auth0";
export const GET = auth0.handler;
export const POST = auth0.handler;
Pages Router — mount the handler in your API route:
// pages/api/auth/[auth0].ts
import { auth0 } from "@/lib/auth0";
export default auth0.handler;
Client-Side Usage
Import the passkey singleton from @auth0/nextjs-auth0/client. Use signup() for new users and login() for returning users — each method handles the full WebAuthn ceremony internally (challenge fetch → navigator.credentials.create/get() → verify).
"use client";
import { useState } from "react";
import { passkey } from "@auth0/nextjs-auth0/client";
export function PasskeyForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
async function handleSignup(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
await passkey.signup({ email, name: email });
window.location.href = "/dashboard";
} catch (err: any) {
if (err.error === "webauthn_error") {
setError(err.error_description ?? "Passkey operation was cancelled.");
} else {
setError(err.error_description ?? "Something went wrong.");
}
}
}
async function handleLogin() {
setError(null);
try {
await passkey.login();
window.location.href = "/dashboard";
} catch (err: any) {
if (err.error === "webauthn_error") {
setError(err.error_description ?? "Passkey operation was cancelled.");
} else {
setError(err.error_description ?? "Something went wrong.");
}
}
}
return (
<>
<form onSubmit={handleSignup}>
{error && <p>{error}</p>}
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="Email" />
<button type="submit">Sign up with passkey</button>
</form>
<button onClick={handleLogin}>Sign in with passkey</button>
</>
);
}
Step-by-step (full control):
For cases where you need to inspect the challenge or credential before verifying, you can call each step individually:
import { passkey } from "@auth0/nextjs-auth0/client";
// Signup — step by step (via Server Actions)
const challenge = await getSignupChallenge({ email: "user@example.com", name: "Jane" });
const credential = await navigator.credentials.create({ publicKey: decodeCreationOptions(challenge.authnParamsPublicKey) });
await verifyPasskey({ authSession: challenge.authSession, authResponse: serializeCredential(credential) });
// Login — step by step (via Server Actions)
const challenge = await getLoginChallenge();
const credential = await navigator.credentials.get({ publicKey: decodeRequestOptions(challenge.authnParamsPublicKey) });
await verifyPasskey({ authSession: challenge.authSession, authResponse: serializeCredential(credential) });
Note
authnParamsPublicKey is returned as a plain object with base64url-encoded buffers. When using the step-by-step API you must decode the challenge, user.id, and allowCredentials[].id fields back to ArrayBuffer before passing them to the WebAuthn API. The one-call signup() and login() methods handle this automatically.
Server-Side (Headless) Usage
Use auth0.passkey directly in Server Actions or API Routes when you want full control over the request/response cycle.
App Router — Server Action:
"use server";
import { auth0 } from "@/lib/auth0";
import type { PasskeyRegisterOptions } from "@auth0/nextjs-auth0";
export async function getSignupChallenge(options: PasskeyRegisterOptions) {
return auth0.passkey.register(options);
}
export async function getLoginChallenge() {
return auth0.passkey.challenge();
}
Pages Router — API Route:
import { auth0 } from "@/lib/auth0";
import type { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const host = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host;
const proto = (Array.isArray(req.headers["x-forwarded-proto"])
? req.headers["x-forwarded-proto"][0]
: req.headers["x-forwarded-proto"]) ?? "https";
const nextReq = new NextRequest(
host ? new URL(req.url!, `${proto}://${host}`) : new URL(req.url!, "https://localhost"),
{ method: req.method, headers: req.headers as HeadersInit }
);
const nextRes = new NextResponse();
await auth0.passkey.getToken(nextReq, nextRes, {
authSession: req.body.authSession,
authResponse: req.body.authResponse
});
const cookies = nextRes.headers.getSetCookie();
if (cookies.length > 0) res.setHeader("Set-Cookie", cookies);
res.status(200).json({ success: true });
}
Error Handling
Passkey errors expose error (the error code) and error_description. Check error === "webauthn_error" to distinguish user cancellation or device capability issues from server errors.
import {
PasskeyRegisterError,
PasskeyChallengeError,
PasskeyGetTokenError
} from "@auth0/nextjs-auth0/errors";
try {
await passkey.signup({ email });
} catch (err) {
if (err instanceof PasskeyRegisterError) {
// err.error — e.g. 'passkeys_not_enabled', 'invalid_request'
// err.error_description — human-readable message
}
if (err instanceof PasskeyGetTokenError) {
if (err.error === "webauthn_error") {
// User cancelled the browser dialog, or device doesn't support passkeys
}
}
}
try {
await passkey.login();
} catch (err) {
if (err instanceof PasskeyChallengeError) {
// Challenge request failed
}
if (err instanceof PasskeyGetTokenError) {
if (err.error === "webauthn_error") {
// User cancelled or device doesn't support passkeys
}
}
}
Passkey Enrollment
Authenticated users can enroll additional passkeys — useful for backup devices or shared computers. Enrollment uses the Auth0 MyAccount API and requires an active session.
Auth0 Setup for Enrollment
- Configure a Multi-Resource Refresh Token (MRRT) policy for your application:
- Audience:
https://{your-domain}/me/ - Scope:
create:me:authentication_methods
- Audience:
- The SDK exchanges the session refresh token for a MyAccount-scoped access token automatically at call time.
Enrollment Route Handlers
Two additional routes are registered automatically:
| Method | Default Path | Purpose |
|---|---|---|
POST | /auth/passkey/enrollment-challenge | Request a WebAuthn creation challenge for the current user (requires session) |
POST | /auth/passkey/enrollment-verify | Verify the attestation and complete enrollment (requires session) |
Server-Side Enrollment
Call auth0.passkey.enrollmentChallenge() and auth0.passkey.enrollmentVerify() directly from a Server Action or API Route:
"use server";
import { auth0 } from "@/lib/auth0";
import type { PasskeyEnrollmentVerifyOptions } from "@auth0/nextjs-auth0";
export async function getEnrollmentChallenge() {
// Requires an active session — throws PasskeyEnrollmentChallengeError if not authenticated
return auth0.passkey.enrollmentChallenge();
}
export async function verifyEnrollment(options: PasskeyEnrollmentVerifyOptions) {
return auth0.passkey.enrollmentVerify(options);
}
Client-Side Enrollment
Call the enrollment route handlers from a client component:
"use client";
import { useState } from "react";
export function PasskeyEnrollForm() {
const [error, setError] = useState<string | null>(null);
async function handleEnroll() {
// Step 1 — get enrollment challenge
const challengeRes = await fetch("/auth/passkey/enrollment-challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({})
});
if (!challengeRes.ok) {
const err = await challengeRes.json().catch(() => ({}));
setError(err.error_description ?? "Failed to get enrollment challenge");
return;
}
const { authenticationMethodId, authSession, authnParamsPublicKey } = await challengeRes.json();
// Step 2 — WebAuthn ceremony
const credential = await navigator.credentials.create({
publicKey: decodeCreationOptions(authnParamsPublicKey) // decode base64url → ArrayBuffer
}) as PublicKeyCredential | null;
if (!credential) return;
// Step 3 — verify enrollment
const verifyRes = await fetch("/auth/passkey/enrollment-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ authenticationMethodId, authSession, authResponse: serializeCredential(credential) })
});
if (!verifyRes.ok) {
const err = await verifyRes.json().catch(() => ({}));
setError(err.error_description ?? "Enrollment verification failed");
return;
}
const method = await verifyRes.json(); // PasskeyAuthenticationMethod
console.log("Enrolled:", method.id, method.key_id);
}
return <button onClick={handleEnroll}>Enroll a passkey</button>;
}
Note
authnParamsPublicKey contains base64url-encoded ArrayBuffer fields (challenge, user.id, excludeCredentials[].id). You must decode them before passing to navigator.credentials.create(). See examples/with-passkeys/components/passkey-enroll-form.tsx for a complete implementation.
Route Configuration
The default route paths can be overridden with environment variables:
# .env.local
NEXT_PUBLIC_PASSKEY_REGISTER_ROUTE=/auth/passkey/register
NEXT_PUBLIC_PASSKEY_CHALLENGE_ROUTE=/auth/passkey/challenge
NEXT_PUBLIC_PASSKEY_GET_TOKEN_ROUTE=/auth/passkey/get-token
NEXT_PUBLIC_PASSKEY_ENROLLMENT_CHALLENGE_ROUTE=/auth/passkey/enrollment-challenge
NEXT_PUBLIC_PASSKEY_ENROLLMENT_VERIFY_ROUTE=/auth/passkey/enrollment-verify
Because these variables are prefixed with NEXT_PUBLIC_, they are inlined by the Next.js bundler and available on the client without an extra API call.
Error Types
| Error Class | error Code | When Thrown |
|---|---|---|
PasskeyRegisterError | passkeys_not_enabled | Passkeys not enabled for the application |
PasskeyRegisterError | invalid_request | Missing or malformed body field |
PasskeyRegisterError | client_error | Network failure or unparseable response |
PasskeyChallengeError | invalid_request | Missing or malformed body field |
PasskeyChallengeError | client_error | Network failure or unparseable response |
PasskeyGetTokenError | webauthn_error | User cancelled the browser dialog, or device doesn't support passkeys |
PasskeyGetTokenError | invalid_grant | Invalid or replayed passkey assertion |
PasskeyGetTokenError | client_error | Network failure or unparseable response |
PasskeyEnrollmentChallengeError | — | No active session when enrollment challenge was requested |
PasskeyEnrollmentChallengeError | — | Enrollment not enabled on the tenant |
PasskeyEnrollmentChallengeError | — | Auth0 did not return a Location header |
PasskeyEnrollmentVerifyError | — | No active session when enrollment verify was called |
PasskeyEnrollmentVerifyError | — | Invalid or replayed attestation |
Silent authentication
Silent authentication checks for an existing Auth0 session without user interaction. Use prompt: 'none' as an authorization parameter.
Custom route:
// app/api/auth/silent/route.ts
import { auth0 } from '@/lib/auth0';
import { NextRequest } from 'next/server';
export const GET = async (req: NextRequest) => {
return auth0.startInteractiveLogin({
authorizationParameters: { prompt: 'none' },
returnTo: req.nextUrl.searchParams.get('returnTo') || '/'
});
};
Built-in route with query param:
<a href="/auth/login?prompt=none">Silent Auth</a>
Error handling:
Auth0 returns login_required when no active session exists. Handle gracefully:
try {
return await auth0.startInteractiveLogin({
authorizationParameters: { prompt: 'none' }
});
} catch (error) {
// Redirect to interactive login
return NextResponse.redirect('/auth/login');
}
DPoP (Demonstrating Proof-of-Possession)
DPoP is an OAuth 2.0 extension that enhances security by binding access tokens to a client's private key. This prevents token theft and replay attacks by requiring cryptographic proof that the client possessing the token also possesses the private key used to request it.
What is DPoP?
DPoP (Demonstrating Proof-of-Possession) provides application-level proof-of-possession security for OAuth 2.0. Key benefits include:
- Token Binding: Access tokens are cryptographically bound to the client's key pair
- Theft Protection: Stolen tokens cannot be used without the corresponding private key
- Replay Attack Prevention: Each request includes a unique proof-of-possession signature
- Enhanced Security: Complements OAuth 2.0 with additional cryptographic guarantees
Basic DPoP Setup
Choose one of three setup methods based on your deployment strategy:
1. Enable DPoP with Generated Keys
For dynamic key generation during application startup:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
import { generateKeyPair } from "oauth4webapi";
// Generate ES256 key pair for DPoP - use this for development or dynamic environments
const dpopKeyPair = await generateKeyPair("ES256");
export const auth0 = new Auth0Client({
useDPoP: true,
dpopKeyPair
});
2. Enable DPoP with Environment Variables
For production deployments with pre-generated keys:
# .env.local - Store your actual key values here
AUTH0_DPOP_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----"
AUTH0_DPOP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQ...
-----END PRIVATE KEY-----"
import { Auth0Client } from "@auth0/nextjs-auth0/server";
// Auth0 client automatically loads keys from environment variables
export const auth0 = new Auth0Client({
useDPoP: true
// Keys loaded automatically from AUTH0_DPOP_* environment variables
});
3. Generate DPoP Keys Using the SDK
For generating keys and exporting them to environment variables:
import { generateDpopKeyPair } from "@auth0/nextjs-auth0/server";
import { exportPKCS8, exportSPKI } from "jose";
// Generate new key pair and export for environment variables
const keyPair = await generateDpopKeyPair();
const publicKeyPem = await exportSPKI(keyPair.publicKey);
const privateKeyPem = await exportPKCS8(keyPair.privateKey);
// Copy these values to your .env.local file
console.log("AUTH0_DPOP_PUBLIC_KEY=" + publicKeyPem);
console.log("AUTH0_DPOP_PRIVATE_KEY=" + privateKeyPem);
Making DPoP-Protected Requests
The recommended approach is to use the createFetcher method, which handles all DPoP complexity automatically.
DPoP Inheritance Behavior
Global Configuration Inheritance
When you enable DPoP globally in your Auth0Client, all fetchers automatically inherit this setting:
// lib/auth0.ts - Global DPoP configuration
export const auth0 = new Auth0Client({
useDPoP: true, // Enable DPoP globally
dpopKeyPair // Your key pair
});
// Fetchers inherit DPoP settings automatically
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com"
// No need to specify useDPoP: true - inherited from global config
});
Per-Fetcher Override
You can override the global DPoP setting for specific fetchers when needed:
// Explicitly enable DPoP (when global setting is false)
const dpopFetcher = await auth0.createFetcher(req, {
baseUrl: "https://secure-api.example.com",
useDPoP: true // Override global setting
});
// Explicitly disable DPoP (when global setting is true)
const legacyFetcher = await auth0.createFetcher(req, {
baseUrl: "https://legacy-api.example.com",
useDPoP: false // Override global setting for legacy API
});
Fallback Behavior
The DPoP configuration follows this precedence order:
- Explicit fetcher option:
options.useDPoP(when specified) - Global Auth0Client setting:
auth0.useDPoP(when fetcher option not specified) - Default:
false(when neither is configured)
This inheritance pattern aligns with auth0-spa-js behavior, providing consistent developer experience across Auth0 SDKs.
Using the Fetcher (Recommended)
App Router Example - Server Components and Route Handlers:
import { auth0 } from "@/lib/auth0";
// Route Handler: app/api/data/route.ts
export async function GET() {
// Create fetcher - DPoP inherited from global Auth0Client configuration
const fetcher = await auth0.createFetcher(undefined, {
baseUrl: "https://api.example.com"
// useDPoP is inherited from global auth0 config
});
// Make authenticated request - DPoP proof generated automatically if enabled globally
const response = await fetcher.fetchWithAuth("/protected-resource", {
method: "GET",
headers: {
"Content-Type": "application/json"
}
});
const data = await response.json();
return Response.json(data);
}
Pages Router Example - API Routes and getServerSideProps:
// API Route: pages/api/data.js
export default async function handler(req, res) {
// Create fetcher with explicit DPoP override for legacy API compatibility
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: false // Explicitly disable DPoP for this legacy API
});
try {
// fetchWithAuth handles access token retrieval (without DPoP)
const response = await fetcher.fetchWithAuth("/protected-data");
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: "Failed to fetch data" });
}
}
DPoP Configuration Options
Fine-tune DPoP behavior for your environment and security requirements.
Clock Tolerance and Skew
Configure timing validation to handle clock differences between client and server:
export const auth0 = new Auth0Client({
useDPoP: true,
dpopOptions: {
// Clock tolerance: Allow up to 60 seconds difference between client/server clocks
clockTolerance: 60,
// Clock skew: Adjust if your server clock is consistently ahead/behind (rare)
clockSkew: 0,
// Retry configuration: Control behavior when DPoP nonce errors occur
retry: {
delay: 200, // Wait 200ms before retry (prevents server overload)
jitter: true // Add randomness to prevent thundering herd effect
}
}
});
Environment Variable Configuration
Configure DPoP settings through environment variables for easier deployment:
# .env.local
# === Required: DPoP Keys ===
AUTH0_DPOP_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..."
AUTH0_DPOP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----..."
# === Optional: Timing Configuration ===
AUTH0_DPOP_CLOCK_SKEW=0 # Default: 0 (no adjustment)
AUTH0_DPOP_CLOCK_TOLERANCE=30 # Default: 30 seconds
# === Optional: Retry Configuration ===
AUTH0_RETRY_DELAY=100 # Default: 100ms delay before retry
AUTH0_RETRY_JITTER=true # Default: true (add randomness)
Error Handling
Handle DPoP-specific errors gracefully with proper error detection and response strategies.
Handling DPoP Errors
Implement comprehensive error handling for DPoP configuration and runtime issues:
import { DPoPError, DPoPErrorCode } from "@auth0/nextjs-auth0/errors";
import { auth0 } from "@/lib/auth0";
try {
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: true
});
const response = await fetcher.fetchWithAuth("/protected-resource");
const data = await response.json();
return Response.json(data);
} catch (error) {
// Check for DPoP-specific errors first
if (error instanceof DPoPError) {
console.error(`DPoP Error [${error.code}]:`, error.message);
// Handle specific DPoP error types
switch (error.code) {
case DPoPErrorCode.DPOP_KEY_EXPORT_FAILED:
// Key configuration problem - check environment variables
return Response.json(
{ error: "DPoP key configuration error" },
{ status: 500 }
);
case DPoPErrorCode.DPOP_JKT_CALCULATION_FAILED:
// Key thumbprint calculation failed - possible key corruption
return Response.json(
{ error: "DPoP thumbprint calculation failed" },
{ status: 500 }
);
default:
return Response.json(
{ error: "DPoP configuration error" },
{ status: 500 }
);
}
}
// Handle non-DPoP errors (network, API errors, etc.)
return Response.json({ error: "Request failed" }, { status: 500 });
}
Automatic Nonce Error Retry
The SDK automatically handles DPoP nonce errors with intelligent retry logic:
// The fetcher automatically retries DPoP nonce errors - no manual handling needed
const response = await fetcher.fetchWithAuth("/api/endpoint");
// Retry flow (handled internally):
// 1. First request → DPoP nonce error (401 with use_dpop_nonce header)
// 2. SDK extracts new nonce from error response
// 3. SDK waits configured delay (with optional jitter)
// 4. SDK retries request with updated nonce
// 5. Success or final failure
Advanced Usage
Custom Access Token Factory
Override the default token retrieval with custom logic for specific use cases:
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: true,
// Custom access token factory - useful for special scopes or audiences
getAccessToken: async (options) => {
// Add custom logic: token caching, audience-specific tokens, etc.
const accessToken = await auth0.getAccessToken(req, {
...options,
audience: "https://special-api.example.com",
scope: "admin:read admin:write"
});
return accessToken.token;
}
});
Custom Access Token Scopes with DPoP
Pass token options directly to individual requests:
// Specify audience and scope per request
const response = await fetcher.fetchWithAuth("/protected-resource", {
scope: "read:admin write:admin", // Request specific scopes
audience: "https://api.example.com", // Target specific API
refresh: true // Force token refresh if needed
});
Conditional DPoP Usage
Enable DPoP selectively based on environment or security requirements:
// Dynamic DPoP configuration based on environment or route sensitivity
const shouldUseDPoP =
process.env.NODE_ENV === "production" ||
request.url.includes("/sensitive-api");
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: shouldUseDPoP // DPoP only for production or sensitive routes
});
Custom Fetch with DPoP
Add logging, metrics, or custom headers while preserving DPoP functionality:
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: true,
// Custom fetch implementation with logging and metrics
fetch: async (request) => {
console.log(`DPoP request to: ${request.url}`);
const startTime = Date.now();
const response = await fetch(request);
const duration = Date.now() - startTime;
// Log response metrics
console.log(`Response: ${response.status} (${duration}ms)`);
// Could add custom headers, retry logic, etc.
return response;
}
});
Token Audience Validation with Multiple APIs
When using DPoP with multiple audiences in the same application (e.g., via MRRT policies), ensure each access token is sent only to its intended API. Sending a token to the wrong API will result in audience validation failures.
How This Can Happen
When creating multiple fetcher instances for different APIs:
// Fetcher for API 1
const fetcher1 = createFetcher({
url: "https://api1.example.com",
accessTokenFactory: () =>
getAccessToken({
audience: "https://api1.example.com"
// ...
})
});
// Fetcher for API 2
const fetcher2 = createFetcher({
url: "https://api2.example.com",
accessTokenFactory: () =>
getAccessToken({
audience: "https://api2.example.com"
// ...
})
});
Common mistake: Accidentally using fetcher1 to call endpoints that should use fetcher2, or vice versa. The API will reject the request with an audience mismatch error like:
OAUTH_JWT_CLAIM_COMPARISON_FAILED: unexpected JWT "aud" (audience) claim value
Mitigation Strategies
1. Scope fetcher instances appropriately
- Create one fetcher per API/audience combination
- Use clear, descriptive variable names that indicate which API each fetcher targets
- Consider namespacing or module organization to prevent confusion
2. Configure MRRT policies correctly
- Ensure your MRRT policies include all audiences your application needs to access
- Set
skip_consent_for_verifiable_first_party_clients: trueon all APIs in MRRT policies - Only include custom scopes in MRRT policies (OIDC scopes like
openid,profile,offline_accessare automatically included)
3. Validate in development
- Log the
audclaim from decoded tokens during development to verify correct routing - Implement error handling that clearly identifies audience mismatches
- Test each fetcher instance against its intended API endpoint before production deployment
4. API server validation
- Ensure your API servers validate the
audclaim matches their expected audience identifier - Use the same audience string in both Auth0 API configuration and server-side validation
Example: Proper Token Routing
// ✅ Correct: Each fetcher calls its own API
await fetcher1.fetchWithAuth("/users"); // Uses token with aud: "https://api1.example.com"
await fetcher2.fetchWithAuth("/orders"); // Uses token with aud: "https://api2.example.com"
// ❌ Incorrect: Wrong fetcher for the API
await fetcher1.fetchWithAuth("https://api2.example.com/orders"); // Will fail with aud mismatch
Remember: JWT audience validation is a critical security feature that prevents token misuse across different resource servers. These errors indicate your security controls are working correctly—the solution is to ensure proper token-to-API routing in your application code.
Security Best Practices
Follow these guidelines for secure DPoP implementation:
- Key Management: Use hardware security modules (HSMs) for key storage in production
- Key Rotation: Implement regular key rotation policies for long-lived applications
- Monitoring: Monitor DPoP error rates to detect potential attacks or configuration issues
- Clock Tolerance: Keep clock tolerance as low as possible (≤ 30 seconds recommended)
- Environment Isolation: Use unique key pairs per environment (dev, staging, production)
- Key Security: Never commit DPoP keys to version control or logs
Troubleshooting
Diagnose and resolve common DPoP configuration and runtime issues.
Common Issues
DPoP keys not found:
WARNING: useDPoP is set to true but dpopKeyPair is not provided.
Solution: Ensure AUTH0_DPOP_PUBLIC_KEY and AUTH0_DPOP_PRIVATE_KEY are set correctly in your environment, or provide the dpopKeyPair option directly in the Auth0Client constructor.
Key pair validation failed:
WARNING: Private and public keys do not form a valid key pair
Solution: Verify that your keys are correctly paired, in PEM format, and use the P-256 elliptic curve. Regenerate keys if necessary using the SDK's generateDpopKeyPair() function.
Clock tolerance warnings:
WARNING: clockTolerance of 300s exceeds recommended maximum of 30s
Solution: Synchronize server clocks using NTP instead of increasing tolerance. High tolerance values weaken DPoP security.
DPoP nonce errors: If you see frequent nonce errors, check:
- Server clock synchronization: Ensure clocks are accurate and synced
- Network stability: Verify stable connection between client and authorization server
- Rate limiting: Check if authorization server is rate limiting requests
Debug Logging
Enable detailed logging to diagnose DPoP request issues:
// Custom fetch with comprehensive DPoP debugging
const fetcher = await auth0.createFetcher(req, {
baseUrl: "https://api.example.com",
useDPoP: true,
fetch: async (request) => {
// Log outgoing request details
console.log(
"DPoP Request Headers:",
Object.fromEntries(request.headers.entries())
);
const response = await fetch(request);
// Log response details, especially for failures
if (!response.ok) {
console.error("DPoP Request Failed:", {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
});
}
return response;
}
});
mTLS (Mutual TLS Client Authentication)
Mutual TLS (mTLS) provides strong client authentication using X.509 certificates instead of client secrets. When enabled, the SDK authenticates to Auth0 using a client TLS certificate, and Auth0 issues certificate-bound access tokens with a cnf.x5t#S256 claim for proof-of-possession protection.
What is mTLS?
mTLS (Mutual TLS) is an authentication method defined in RFC 8705 where the client proves its identity using an X.509 certificate during the TLS handshake, instead of sending a client secret in the request body. This provides several security benefits:
- No secrets in transit — The certificate is presented during TLS, never in the HTTP request
- Certificate-bound tokens — Access tokens include a
cnfclaim binding them to the client certificate - Proof-of-possession — Resource servers can verify the token presenter holds the private key
Prerequisites
Auth0 Tenant:
- Custom domain configured with mTLS enabled (see Auth0 docs)
- Discovery document must include
mtls_endpoint_aliases - Application Token Endpoint Authentication Method set to:
- "Self-Signed TLS Client Authentication" for self-signed certificates
- "TLS Client Authentication" for CA-issued certificates
Client Certificate:
- Valid X.509 certificate (self-signed for testing, CA-issued for production)
- Private key accessible to your Next.js server
- Certificate uploaded to Auth0 Dashboard → Applications → Credentials
Basic mTLS Setup
1. Generate Client Certificate
Option A: Self-Signed Certificate (Development/Testing)
Self-signed certificates are quick to generate and ideal for local development and testing:
# Create certificate directory
mkdir -p certs
# Generate private key
openssl genrsa -out certs/client.key 2048
# Generate self-signed certificate (valid 365 days)
openssl req -new -x509 -key certs/client.key \
-out certs/client.crt -days 365 \
-subj "/CN=nextjs-mtls-client/O=YourOrg/C=US"
# Get certificate fingerprint (needed for Auth0)
openssl x509 -in certs/client.crt -noout -fingerprint -sha256
Option B: CA-Issued Certificate (Production)
For production, obtain a certificate from a trusted Certificate Authority:
# Generate private key
openssl genrsa -out certs/client.key 2048
# Generate Certificate Signing Request (CSR)
openssl req -new -key certs/client.key -out certs/client.csr \
-subj "/CN=nextjs-mtls-client/O=YourOrg/C=US"
# Submit CSR to your CA (Let's Encrypt, DigiCert, or internal CA)
# CA will return: client.crt (signed certificate) and ca-bundle.crt (CA chain)
# Verify the certificate chain
openssl verify -CAfile certs/ca-bundle.crt certs/client.crt
2. Configure Auth0
For Self-Signed Certificates:
-
Upload Certificate:
- Go to Auth0 Dashboard → Applications → Your App → Credentials
- Click "Add Credential"
- Select "Self-Signed Certificate"
- Upload or paste the contents of
certs/client.crt - Verify the fingerprint matches what
opensslshows
-
Set Authentication Method:
- Go to Settings → Advanced → OAuth
- Token Endpoint Authentication Method: "Self-Signed TLS Client Authentication"
For CA-Issued Certificates:
-
Upload CA Root Certificate (Tenant-Level):
- Some Auth0 configurations require uploading the CA's root certificate at the tenant level
- Contact Auth0 support if your CA is not in Auth0's trust store
-
Set Authentication Method:
- Go to Settings → Advanced → OAuth
- Token Endpoint Authentication Method: "TLS Client Authentication" (not "Self-Signed")
-
Configure Callbacks (Both Types):
- Allowed Callback URLs:
http://localhost:3000/auth/callback - Allowed Logout URLs:
http://localhost:3000
- Allowed Callback URLs:
Comparison: Self-Signed vs CA-Issued
| Aspect | Self-Signed | CA-Issued |
|---|---|---|
| Use Case | Development, testing, isolated environments | Production, public-facing applications |
| Certificate Issuance | Generated locally with openssl | Obtained from trusted CA (commercial or internal) |
| Auth0 Upload | Must upload individual certificate fingerprint per application | CA root may be trusted at tenant level (depends on CA) |
| Certificate Rotation | Must re-upload to Auth0, restart application | May rotate locally if CA trusted; simpler renewal workflow |
| Trust Validation | Auth0 validates against uploaded fingerprint | Auth0 validates chain to trusted CA root |
| Token Endpoint Auth | self_signed_tls_client_auth | tls_client_auth |
| Cost | Free | CA fees may apply (Let's Encrypt is free) |
| Security | Secure if properly managed | Higher trust level, auditable chain |
3. Configure the SDK
Install undici for TLS client certificate support:
npm install undici
Configure Next.js to externalize undici in next.config.ts:
export default {
serverExternalPackages: ["undici"]
};
Create the Auth0 client with mTLS in lib/auth0.ts:
import { readFileSync } from "fs";
import { Agent, fetch as undiciFetch } from "undici";
import { Auth0Client } from "@auth0/nextjs-auth0/server";
// Load client certificate and private key
const cert = readFileSync(process.env.MTLS_CLIENT_CERT_PATH!);
const key = readFileSync(process.env.MTLS_CLIENT_KEY_PATH!);
// Create undici Agent with TLS client certificate
const tlsAgent = new Agent({
connect: { cert, key }
});
// Wrapper that attaches the TLS agent to every request
function mtlsFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return undiciFetch(input as Parameters<typeof undiciFetch>[0], {
...(init as Parameters<typeof undiciFetch>[1]),
dispatcher: tlsAgent
}) as unknown as Promise<Response>;
}
// Configure Auth0 client with mTLS
export const auth0 = new Auth0Client({
useMtls: true, // Enable mTLS authentication
customFetch: mtlsFetch, // Use cert-carrying fetch
});
Set environment variables in .env.local:
AUTH0_DOMAIN=your-custom-domain.com # Must have mTLS enabled
AUTH0_CLIENT_ID=your-client-id
AUTH0_SECRET=your-session-secret
APP_BASE_URL=http://localhost:3000
MTLS_CLIENT_CERT_PATH=./certs/client.crt
MTLS_CLIENT_KEY_PATH=./certs/client.key
Note: Middleware requires Node.js runtime. Add to middleware.ts:
export const runtime = "nodejs"; // Required for fs.readFileSync and undici
How It Works
When useMtls: true is configured:
- TLS Handshake: The
undiciAgent presents the client certificate during TLS connection establishment - Token Request: SDK uses
TlsClientAuth()— sends onlyclient_id, noclient_secretin the request body - Endpoint Routing: Requests go to
mtls_endpoint_aliases.token_endpointfrom the discovery document - Certificate Validation: Auth0 validates the certificate fingerprint against what's uploaded in the Dashboard
- Token Binding: Auth0 issues an access token with
cnf.x5t#S256claim containing the certificate fingerprint
Certificate-Bound Token Example:
{
"iss": "https://your-domain.com/",
"sub": "auth0|123456",
"aud": "https://api.example.com",
"cnf": {
"x5t#S256": "GRq9CsEnEBZ_VT-5WjZFb7ovYpKR5UlG08tTg4HfzCs"
}
}
Resource servers can verify the token presenter holds the matching private key by checking the cnf claim against the TLS client certificate.
Example Application
See examples/with-mtls for a complete working example with:
- Full Next.js App Router setup
- Self-signed certificate generation
- Local testing scripts
- Comprehensive testing guide
Testing
The example includes a local test script to verify the mTLS setup works before connecting to Auth0:
cd examples/with-mtls
node test-mtls.mjs
This spins up a local HTTPS server that requests a client certificate and validates that undici correctly attaches it during the TLS handshake.
Important Notes
-
Custom Domain Required: Default Auth0 domains (
*.auth0.com) don't support mTLS. You must configure a custom domain with mTLS enabled. -
Discovery Verification: Check that
mtls_endpoint_aliasesappears in your discovery document:curl -s https://your-domain.com/.well-known/openid-configuration | grep mtls_endpoint_aliases -
Certificate Security:
- Never commit private keys (
.keyfiles) to version control - Use
.gitignoreto excludecerts/directory - Store certificates securely in production:
- Environment variables (for containerized deployments)
- Secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
- Kubernetes Secrets (for K8s deployments)
- Set file permissions:
chmod 400 certs/client.key(read-only for owner) - Monitor certificate expiration and automate rotation before expiry
- Never commit private keys (
-
Edge Runtime Limitation: mTLS requires Node.js runtime because:
fs.readFileSyncis needed to load certificate filesundicirequires Node.js built-ins
-
Certificate Rotation:
- Self-signed: Must re-upload new certificate to Auth0 Dashboard, restart application
- CA-issued: If Auth0 trusts the CA at tenant level, can rotate locally without Auth0 changes
- Set up monitoring 30-60 days before expiration
- Use cert management tools:
certbot(Let's Encrypt),cert-manager(K8s)
-
Environment-Specific Certificates: Use different certificates per environment (dev, staging, production) for proper isolation.
-
Passwordless Start Not Supported:
/passwordless/startdoes not accept mTLS client authentication — Auth0's passport strategy list for that endpoint omits both mTLS strategies. An mTLS-only client (noclientSecret) will receiveinvalid_clientwhen callingpasswordlessStart. All other SDK interactive flows —mfaVerify(/mfa/challenge),passkeyGetToken(/passkey/challenge,/passkey/register) — accept mTLS and route token exchange through the mTLS alias automatically.
Proxy Handler for My Account and My Organization APIs
The SDK provides built-in proxy handler support for Auth0's My Account and My Organization Management APIs. This enables browser-initiated requests to these APIs while maintaining server-side DPoP authentication and token management.
Overview
The proxy handler implements a Backend-for-Frontend (BFF) pattern that transparently forwards client requests to Auth0 APIs through the Next.js server. This architecture ensures:
- DPoP private keys and tokens remain on the server, inaccessible to client-side JavaScript
- Automatic token retrieval and refresh based on requested audience and scope
- DPoP proof generation for each proxied request
- Session updates when tokens are refreshed
- Proper CORS handling for cross-origin requests
The proxy handler is automatically enabled when using the SDK's middleware and requires no additional configuration.
How It Works
When a client makes a request to /me/* or /my-org/* on your Next.js application:
- The SDK's middleware intercepts the request
- Validates the user's session exists
- Retrieves or refreshes the appropriate access token for the requested audience
- Generates DPoP proof if DPoP is enabled
- Forwards the request to the upstream Auth0 API with proper authentication headers
- Returns the response to the client
- Updates the session if tokens were refreshed
My Account API Proxy
The My Account API proxy handles all requests to Auth0's My Account API at /me/v1/*.
Configuration
Enable My Account API access by configuring the audience and scopes:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
useDPoP: true,
authorizationParameters: {
audience: "urn:your-api-identifier",
scope: {
[`https://${process.env.AUTH0_DOMAIN}/me/`]:
"profile:read profile:write factors:manage"
}
}
});
Client-Side Usage
Make requests to the My Account API through the /me/* path:
"use client";
import { useState } from "react";
export default function MyAccountProfile() {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(false);
const fetchProfile = async () => {
setLoading(true);
try {
const response = await fetch("/me/v1/profile", {
method: "GET",
headers: {
scope: "profile:read"
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setProfile(data);
} catch (error) {
console.error("Failed to fetch profile:", error);
} finally {
setLoading(false);
}
};
const updateProfile = async (updates) => {
try {
const response = await fetch("/me/v1/profile", {
method: "PATCH",
headers: {
"content-type": "application/json",
scope: "profile:write"
},
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error("Failed to update profile:", error);
throw error;
}
};
return (
<div>
<button onClick={fetchProfile} disabled={loading}>
{loading ? "Loading..." : "Load Profile"}
</button>
{profile && <pre>{JSON.stringify(profile, null, 2)}</pre>}
</div>
);
}
scope Header
The scope header specifies the scope required for the request. The SDK uses this to retrieve an access token with the appropriate scope for the My Account API audience.
Format: "scope": "scope1 scope2 scope3"
Common scopes for My Account API:
profile:read- Read user profile informationprofile:write- Update user profile informationfactors:read- Read enrolled MFA factorsfactors:manage- Manage MFA factorsidentities:read- Read linked identitiesidentities:manage- Link and unlink identities
My Organization API Proxy
The My Organization API proxy handles all requests to Auth0's My Organization Management API at /my-org/*.
Configuration
Enable My Organization API access by configuring the audience and scopes:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
useDPoP: true,
authorizationParameters: {
audience: "urn:your-api-identifier",
scope: {
[`https://${process.env.AUTH0_DOMAIN}/my-org/`]:
"org:read org:write members:read"
}
}
});
Client-Side Usage
Make requests to the My Organization API through the /my-org/* path:
"use client";
import { useEffect, useState } from "react";
export default function MyOrganization() {
const [organizations, setOrganizations] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchOrganizations();
}, []);
const fetchOrganizations = async () => {
setLoading(true);
try {
const response = await fetch("/my-org/organizations", {
method: "GET",
headers: {
scope: "org:read"
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setOrganizations(data.organizations || []);
} catch (error) {
console.error("Failed to fetch organizations:", error);
} finally {
setLoading(false);
}
};
const updateOrganization = async (orgId, updates) => {
try {
const response = await fetch(`/my-org/organizations/${orgId}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
scope: "org:write"
},
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error("Failed to update organization:", error);
throw error;
}
};
if (loading) return <div>Loading organizations...</div>;
return (
<div>
<h1>My Organizations</h1>
<ul>
{organizations.map((org) => (
<li key={org.id}>{org.display_name}</li>
))}
</ul>
</div>
);
}
Common scopes for My Organization API:
org:read- Read organization informationorg:write- Update organization informationmembers:read- Read organization membersmembers:manage- Manage organization membersroles:read- Read organization rolesroles:manage- Manage organization roles
Integration with UI Components
When using Auth0 UI Components with the proxy handler, configure the client to target the proxy endpoints:
import { MyAccountClient } from "@auth0/my-account-js";
const myAccountClient = new MyAccountClient({
domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN,
baseUrl: "/me",
fetcher: (url, init, authParams) => {
return fetch(url, {
...init,
headers: {
...init?.headers,
scope: authParams?.scope?.join(" ") || ""
}
});
}
});
This configuration:
- Sets
baseUrlto/meto route requests through the proxy - Passes the required scope via the
scopeheader - Ensures the SDK middleware handles authentication transparently
HTTP Methods
The proxy handler supports all standard HTTP methods:
GET- Retrieve resourcesPOST- Create resourcesPUT- Replace resourcesPATCH- Update resourcesDELETE- Remove resourcesOPTIONS- CORS preflight requests (handled without authentication)HEAD- Retrieve headers only
CORS Handling
The proxy handler correctly handles CORS preflight requests (OPTIONS with access-control-request-method header) by forwarding them to the upstream API without authentication headers, as required by RFC 7231 §4.3.1.
CORS headers from the upstream API are forwarded to the client transparently.
Error Handling
The proxy handler returns appropriate HTTP status codes:
401 Unauthorized- No active session or token refresh failed4xx Client Error- Forwarded from upstream API5xx Server Error- Forwarded from upstream API or proxy internal error
Error responses from the upstream API are forwarded to the client with their original status code, headers, and body.
Token Management
The proxy handler automatically:
- Retrieves access tokens from the session for the requested audience
- Refreshes expired tokens using the refresh token
- Updates the session with new tokens after refresh
- Caches tokens per audience to minimize token endpoint calls
- Generates DPoP proofs for each request when DPoP is enabled
Security Considerations
The proxy handler implements secure forwarding:
- HTTP-only session cookies are not forwarded to upstream APIs
- Authorization headers from the client are replaced with server-generated tokens
- Hop-by-hop headers are stripped per RFC 2616 §13.5.1
- Only allow-listed request headers are forwarded
- Response headers are filtered before returning to the client
- Host header is updated to match the upstream API
Debugging
Enable debug logging to troubleshoot proxy requests:
export const auth0 = new Auth0Client({
// ... other config
enableDebugLogs: true
});
This will log:
- Request proxying flow
- Token retrieval and refresh operations
- DPoP proof generation
- Session updates
- Errors and warnings
<Auth0Provider />
Passing an initial user from the server
You can wrap your components in an <Auth0Provider /> and pass an initial user object to make it available to your components using the useUser() hook. For example:
import { Auth0Provider } from "@auth0/nextjs-auth0";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export default async function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth0.getSession();
return (
<html lang="en">
<body>
<Auth0Provider user={session?.user}>{children}</Auth0Provider>
</body>
</html>
);
}
The loaded user will then be used as a fallback in useUser() hook.
Hooks
The SDK exposes hooks to enable you to provide custom logic that would be run at certain lifecycle events.
beforeSessionSaved
The beforeSessionSaved hook is run right before the session is persisted. It provides a mechanism to modify the session claims before persisting them.
The hook recieves a SessionData object and an ID token. The function must return a Promise that resolves to a SessionData object: (session: SessionData) => Promise<SessionData>. For example:
import {
Auth0Client,
filterDefaultIdTokenClaims
} from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
async beforeSessionSaved(session, idToken) {
return {
...session,
user: {
...filterDefaultIdTokenClaims(session.user),
foo: session.user.foo // keep the foo claim
}
};
}
});
The session.user object passed to the beforeSessionSaved hook will contain every claim in the ID Token, including custom claims. You can use the filterDefaultIdTokenClaims utility to filter out the standard claims and only keep the custom claims you want to persist.
[!INFO]
Incase you want to understand which claims are being considered the default Id Token Claims, you can refer toDEFAULT_ID_TOKEN_CLAIMS, which can be imported from the SDK from@auth0/nextjs-auth0/server:import { DEFAULT_ID_TOKEN_CLAIMS } from "@auth0/nextjs-auth0/server";
Alternatively, you can use the entire session.user object if you would like to include every claim in the ID Token by just returning the session like so:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
async beforeSessionSaved(session, idToken) {
return session;
}
});
Do realize that this has an impact on the size of the cookie being issued, so it's best to limit the claims to only those that are necessary for your application.
onCallback
The onCallback hook is run once the user has been redirected back from Auth0 to your application with either an error or the authorization code which will be verified and exchanged.
The onCallback hook receives 3 parameters:
error: the error returned from Auth0 or when attempting to complete the transaction. This will benullif the transaction was completed successfully.context: provides context on the transaction that initiated the transaction.session: theSessionDatathat will be persisted once the transaction completes successfully. This will benullif there was an error.
The hook must return a Promise that resolves to a NextResponse.
For example, a custom onCallback hook may be specified like so:
export const auth0 = new Auth0Client({
async onCallback(error, context, session) {
const appBaseUrl = context.appBaseUrl ?? process.env.APP_BASE_URL;
// redirect the user to a custom error page
if (error) {
return NextResponse.redirect(
new URL(`/error?error=${error.message}`, appBaseUrl)
);
}
// complete the redirect to the provided returnTo URL
return NextResponse.redirect(
new URL(context.returnTo || "/", appBaseUrl)
);
}
});
Session configuration
The session configuration can be managed by specifying a session object when configuring the Auth0 client, like so:
export const auth0 = new Auth0Client({
session: {
rolling: true,
absoluteDuration: 60 * 60 * 24 * 30, // 30 days in seconds
inactivityDuration: 60 * 60 * 24 * 7 // 7 days in seconds
}
});
| Option | Type | Description |
|---|---|---|
| rolling | boolean | When enabled, the session will continue to be extended as long as it is used within the inactivity duration. Once the upper bound, set via the absoluteDuration, has been reached, the session will no longer be extended. Default: true. |
| absoluteDuration | number | The absolute duration after which the session will expire. The value must be specified in seconds. Default: 3 days. |
| inactivityDuration | number | The duration of inactivity after which the session will expire. The value must be specified in seconds. Default: 1 day. |
Understanding Rolling Sessions
Rolling sessions provide a seamless user experience by automatically extending session lifetime as users actively use your application. Here's how they work:
How rolling sessions work:
- Each request to your application extends the session by the
inactivityDuration - Sessions are only extended if used within the inactivity window
- Once the
absoluteDurationis reached, sessions expire regardless of activity - Session extension happens transparently without user intervention
Middleware requirement: Rolling sessions require the authentication middleware to run on all requests. This is why the recommended middleware matcher is broad:
// ✅ CORRECT: Broad matcher enables rolling sessions
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)]
};
// ❌ INCORRECT: Narrow matcher breaks rolling sessions
export const config = {
matcher: ["/dashboard/:path*", "/profile/:path*"]
};
Why broad middleware is necessary:
- Session extension: Each page request extends the session lifetime
- Consistent auth state: Ensures authentication status is up-to-date across all pages
- Security headers: Applies no-cache headers to prevent caching of authenticated content
Warning
Disabling rolling sessions changes the user experience significantly. Users will be logged out after the absolute duration regardless of their activity level, requiring manual re-authentication.
Cookie Configuration
You can configure the session cookie attributes either through environment variables or directly in the SDK initialization.
1. Using Environment Variables:
Set the desired environment variables in your .env.local file or your deployment environment:
# .env.local
# ... other variables ...
# Cookie Options
AUTH0_COOKIE_DOMAIN='.example.com' # Set cookie for subdomains
AUTH0_COOKIE_PATH='/app' # Limit cookie to /app path
AUTH0_COOKIE_TRANSIENT=true # Make cookie transient (session-only)
AUTH0_COOKIE_SECURE=true # Recommended for production; enforced when appBaseUrl is omitted
AUTH0_COOKIE_SAME_SITE='Lax'
The SDK will automatically pick up these values. Note that httpOnly is always set to true for security reasons and cannot be configured.
2. Using Auth0ClientOptions:
Configure the options directly when initializing the client:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
session: {
cookie: {
domain: ".example.com",
path: "/app",
transient: true,
// httpOnly is always true and cannot be configured
secure: process.env.NODE_ENV === "production",
sameSite: "Lax"
// name: 'appSession', // Optional: custom cookie name, defaults to '__session'
}
// ... other session options like absoluteDuration ...
}
// ... other client options ...
});
Session Cookie Options:
domain(String): Specifies theDomainattribute.path(String): Specifies thePathattribute. Defaults to/.transient(Boolean): Iftrue, themaxAgeattribute is omitted, making it a session cookie. Defaults tofalse.secure(Boolean): Specifies theSecureattribute. Defaults tofalse(ortrueifAUTH0_COOKIE_SECURE=trueis set, or whenappBaseUrlis omitted in production).sameSite('Lax' | 'Strict' | 'None'): Specifies theSameSiteattribute. Defaults toLax(or the value ofAUTH0_COOKIE_SAME_SITE).name(String): The name of the session cookie. Defaults to__session.
[!INFO] Options provided directly in
Auth0ClientOptionstake precedence over environment variables. ThehttpOnlyattribute is alwaystrueregardless of configuration.
[!INFO] The
httpOnlyattribute for the session cookie is always set totruefor security reasons and cannot be configured via options or environment variables.
Transaction Cookie Configuration
Customizing Transaction Cookie Expiration
You can configure transaction cookies expiration by providing a maxAge property for transactionCookie.
export const auth0 = new Auth0Client({
transactionCookie: {
maxAge: 1800, // 30 minutes (in seconds)
// ... other options
},
}
Transaction cookies are used to maintain state during authentication flows. The SDK provides several configuration options to manage transaction cookie behavior and prevent cookie accumulation issues.
Transaction Management Modes
Parallel Transactions (Default)
const authClient = new Auth0Client({
enableParallelTransactions: true // Default: allows multiple concurrent logins
// ... other options
});
Single Transaction Mode
const authClient = new Auth0Client({
enableParallelTransactions: false // Only one active transaction at a time
// ... other options
});
Use Parallel Transactions (Default) When:
- Users might open multiple tabs and attempt to log in simultaneously
- You want maximum compatibility with typical user behavior
- Your application supports multiple concurrent authentication flows
Use Single Transaction Mode When:
- You want to prevent cookie accumulation issues in applications with frequent login attempts
- You prefer simpler transaction management
- Users typically don't need multiple concurrent login flows
- You're experiencing cookie header size limits due to abandoned transaction cookies edge cases
Transaction Cookie Options
| Option | Type | Description |
|---|---|---|
| cookieOptions.maxAge | number | The expiration time for transaction cookies in seconds. Defaults to 3600 (1 hour). After this time, abandoned transaction cookies will expire automatically. |
| cookieOptions.prefix | string | The prefix for transaction cookie names. Defaults to __txn_. In parallel mode, cookies are named __txn_{state}. In single mode, just __txn_. |
| cookieOptions.sameSite | "strict" | "lax" | "none" | Controls when the cookie is sent with cross-site requests. Defaults to "lax". |
| cookieOptions.secure | boolean | When true, the cookie will only be sent over HTTPS connections. Derived from appBaseUrl when available; enforced in production when appBaseUrl is omitted. |
| cookieOptions.path | string | Specifies the URL path for which the cookie is valid. Defaults to "/". |
Database sessions
By default, the user's sessions are stored in encrypted cookies. You may choose to persist the sessions in your data store of choice.
To do this, you can provide a SessionStore implementation as an option when configuring the Auth0 client, like so:
export const auth0 = new Auth0Client({
sessionStore: {
async get(id) {
// query and return a session by its ID
},
async set(id, sessionData) {
// upsert the session given its ID and sessionData
},
async delete(id) {
// delete the session using its ID
},
async update(id, sessionData) {
// Optional: update the session by its ID only if it already exists; return true if updated, false if not found.
// Prevents a session deleted by logout from being re-created by a concurrent in-flight rolling write.
//
// MUST be a single atomic operation — do not use get() + set() here,
// as that would recreate the TOCTOU race condition this method is designed to prevent.
//
// Example (PostgreSQL):
// const r = await db.query(
// "UPDATE sessions SET data=\$2, expires_at=NOW()+interval'1 day' WHERE id=\$1",
// [id, sessionData]
// );
// return r.rowCount > 0;
//
// Example (Redis — Lua script for atomicity):
// const lua = `if redis.call('exists',KEYS[1])==1 then
// redis.call('set',KEYS[1],ARGV[1],'EX',ARGV[2]) return 1
// else return 0 end`;
// return await redis.eval(lua, 1, id, JSON.stringify(sessionData), ttl) === 1;
},
async deleteByLogoutToken({ sid, sub }: { sid?: string; sub?: string }) {
// optional method to be implemented when using Back-Channel Logout
}
}
});
Using Client-Initiated Backchannel Authentication
Using Client-Initiated Backchannel Authentication can be done by calling getTokenByBackchannelAuth():
import { auth0 } from "@/lib/auth0";
const tokenResponse = await auth0.getTokenByBackchannelAuth({
bindingMessage: "",
loginHint: {
sub: "auth0|123456789"
}
});
bindingMessage: A human-readable message to be displayed at the consumption device and authentication device. This allows the user to ensure the transaction initiated by the consumption device is the same that triggers the action on the authentication device.loginHint.sub: Thesubclaim of the user that is trying to login using Client-Initiated Backchannel Authentication, and to which a push notification to authorize the login will be sent.
Important
Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard.
Read the Auth0 docs to learn more about Client-Initiated Backchannel Authentication.
Connected Accounts
The SDK can be configured to mount an endpoint to facilitate the connected accounts flow. To mount this route, set the enableConnectAccountEndpoint option to true when instantiating the Auth0 client, like so:
// ./lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
enableConnectAccountEndpoint: true
});
By default, the route will be mounted at /auth/connect. You can customize this path by specifying a routes.connectAccount option, like so:
// ./lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
enableConnectAccountEndpoint: true,
routes: {
connectAccount: "/auth/connect"
}
});
The connect endpoint (/auth/connect or your custom path) accepts the following query parameters:
connection: (required) the name of the connection to use for linking the accountreturnTo: (optional) the URL to redirect the user to after they have completed the connection flow.scopes: (optional) defines the permissions that the client requests from the Identity Provider.. Can be specified as multiple values (e.g.,?scopes=openid&scopes=profile&scopes=email) or using bracket notation (e.g.,?scopes[]=openid&scopes[]=profile&scopes[]=email).- Any additional parameters will be passed as the
authorizationParamsin the call to/me/v1/connected-accounts/connect.
Important
You must enable Offline Access from the Connection Permissions settings to be able to use the connection with Connected Accounts.
onCallback hook
When a user is redirected back to your application after completing the connected accounts flow, the onCallback hook will be called. You can use this hook to run custom logic after the user has connected their account, like so:
import { NextResponse } from "next/server";
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
async onCallback(err, ctx, session) {
const appBaseUrl = ctx.appBaseUrl ?? process.env.APP_BASE_URL;
// `ctx` will contain the following properties when handling a connected account callback:
// - `connectedAccount`: the connected account object (`CompleteConnectAccountResponse`) if the connection was successful
// - `responseType`: will be set to `connect_code` when handling a connected accounts callback (`RESPONSE_TYPES.ConnectCode`)
// - `returnTo`: the returnTo URL specified when calling the connect endpoint (if any)
return NextResponse.redirect(
new URL(ctx.returnTo ?? "/", appBaseUrl)
);
},
enableConnectAccountEndpoint: true
});
connectAccount method
In case you'd like to have more control over the connected accounts flow, a connectAccount method is also available on the Auth0 client instance. For example, you could mount a custom route to start the connected accounts flow, like so:
import { auth0 } from "@/lib/auth0";
export async function GET() {
const res = await auth0.connectAccount({
connection: "my-connection",
scopes: ["openid", "profile", "offline_access", "read:something"],
authorizationParams: {
prompt: "consent",
audience: "https://myapi.com"
},
returnTo: "/connected"
});
return res;
}
Important
You must enable Offline Access from the Connection Permissions settings to be able to use the connection with Connected Accounts.
Back-Channel Logout
The SDK can be configured to listen to Back-Channel Logout events. By default, a route will be mounted /auth/backchannel-logout which will verify the logout token and call the deleteByLogoutToken method of your session store implementation to allow you to remove the session.
To use Back-Channel Logout, you will need to provide a session store implementation as shown in the Database sessions section above with the deleteByLogoutToken implemented.
A LogoutToken object will be passed as the parameter to deleteByLogoutToken which will contain either a sid claim, a sub claim, or both.
Session Expiry from the Upstream IdP
For enterprise connections, the upstream identity provider can cap how long a user's session lives. When the connection is configured to honor it, Auth0 includes a session_expiry claim in the ID token, and the SDK enforces this ceiling automatically. Once it is reached, getSession() returns null and useUser() reflects the logged-out state.
The error surfaced by getAccessToken() depends on the call path:
- Browser client (
getAccessToken()from@auth0/nextjs-auth0/client) — calls/auth/access-tokenwhich hitsgetTokenSetdirectly. ThrowsAccessTokenErrorwith codesession_expired. - Server / App Router (
auth0.getAccessToken()) — callsgetSession()first. Since the ceiling clears the session, it throwsAccessTokenErrorwith codemissing_session, identical to a logged-out user.
To enable this, add a Post-Login Action on your Auth0 tenant that sets the session_expiry claim (see Auth0 Actions documentation). The value must be a Unix timestamp in seconds — a common mistake is using Date.now() (milliseconds) instead of Math.floor(Date.now() / 1000).
Note
Once this feature is active, getSession() and useUser() can return null for a user who was previously logged in once the ceiling passes. If your app assumes a session always exists after login, add a null check and redirect back to login.
The SDK enforces the ceiling 30 seconds early to absorb clock skew — a session is treated as expired slightly before the wall-clock ceiling, never after.
The ceiling is stamped at login and survives access-token refreshes. Refreshing a token does not extend the upstream session; the original session_expiry value is preserved unchanged across all token grants.
On the browser client path, catch session_expired and redirect to re-authenticate:
import { getAccessToken } from "@auth0/nextjs-auth0/client";
import { AccessTokenErrorCode } from "@auth0/nextjs-auth0/errors";
try {
const token = await getAccessToken();
// use token...
} catch (err: any) {
if (err?.code === AccessTokenErrorCode.SESSION_EXPIRED) {
// IdP session ceiling reached — the user must log in again
window.location.href = "/auth/login";
}
throw err;
}
On the server path, handle missing_session (which covers both logged-out and ceiling-expired states):
import { auth0 } from "@/lib/auth0";
import { AccessTokenErrorCode } from "@auth0/nextjs-auth0/errors";
try {
const { token } = await auth0.getAccessToken();
// use token...
} catch (err: any) {
if (err?.code === AccessTokenErrorCode.MISSING_SESSION) {
// No active session — either logged out or ceiling reached
redirect("/auth/login");
}
throw err;
}
Combining middleware
By default, the middleware does not protect any pages. It is used to mount the authentication routes and provide the necessary functionality for rolling sessions.
You can combine multiple middleware, like so:
[!WARNING] > Handling
x-middleware-nextHeader Theauth0.middlewareresponse (authResponse) might contain anx-middleware-nextheader. This header signals to Next.js that the request should be forwarded to the backend application, regardless of the status code of the response you construct.When combining middleware, do not copy the
x-middleware-nextheader fromauthResponseto your final response if your custom middleware intends to block the request (e.g., by returning aNextResponse.jsonwith a 401 status, or aNextResponse.redirect). Copying this header in such cases will cause Next.js to still execute the backend route handler despite your middleware attempting to block access. Only copy headers that are necessary, likeset-cookie.
export async function middleware(request: NextRequest) {
const authResponse = await auth0.middleware(request);
// if path starts with /auth, let the auth middleware handle it
if (request.nextUrl.pathname.startsWith("/auth")) {
return authResponse;
}
// call any other middleware here
const someOtherResponse = await someOtherMiddleware(request);
const shouldProceed = someOtherResponse.headers.get("x-middleware-next");
// add any headers from the auth middleware to the response
for (const [key, value] of authResponse.headers) {
// Only copy 'x-middleware-next' if the custom middleware response intends to proceed.
if (key.toLowerCase() === "x-middleware-next" && !shouldProceed) {
continue; // Skip copying this header if we are blocking/redirecting
}
someOtherResponse.headers.set(key, value);
}
return someOtherResponse;
}
For a complete example using next-intl middleware, please see the examples/ directory of this repository.
ID Token claims and the user object
By default, the following properties claims from the ID token are added to the user object in the session automatically:
subnamenicknamegiven_namefamily_namepictureemailemail_verifiedorg_id
If you'd like to customize the user object to include additional custom claims from the ID token, you can use the beforeSessionSaved hook (see beforeSessionSaved hook)
Note
It's best practice to limit what claims are stored on the user object in the session to avoid bloating the session cookie size and going over browser limits.
Routes
The SDK mounts 6 routes:
/auth/login: the login route that the user will be redirected to to start a initiate an authentication transaction/auth/logout: the logout route that must be added to your Auth0 application's Allowed Logout URLs/auth/callback: the callback route that must be added to your Auth0 application's Allowed Callback URLs/auth/profile: the route to check the user's session and return their attributes/auth/access-token: the route to check the user's session and return an access token (which will be automatically refreshed if a refresh token is available)/auth/backchannel-logout: the route that will receive alogout_tokenwhen a configured Back-Channel Logout initiator occurs
Note
The /auth/access-token response includes token, expires_at (seconds since epoch), expires_in (TTL seconds), optional scope, and optional token_type.
Custom routes
The default paths can be set using the routes configuration option. For example, when instantiating the client:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
routes: {
login: "/login",
logout: "/logout",
callback: "/callback",
backChannelLogout: "/backchannel-logout",
profile: "/api/me",
accessToken: "/api/auth/token"
}
});
Note
If you customize the login url you will need to set the environment variable NEXT_PUBLIC_LOGIN_ROUTE to this custom value for withPageAuthRequired to work correctly.
Configuring routes for client-side usage
When customizing the profile and accessToken routes, you need to ensure that client-side functions (useUser, getAccessToken) and the Auth0Provider use the correct routes. There are two approaches:
Option 1: Using environment variables (recommended for most cases)
Set the environment variables in your .env.local file:
# .env.local
# required environment variables...
NEXT_PUBLIC_PROFILE_ROUTE=/api/me
NEXT_PUBLIC_ACCESS_TOKEN_ROUTE=/api/auth/token
Option 2: Passing routes programmatically (recommended for multi-tenant applications)
For multi-tenant applications where routes may vary by tenant at runtime, you can pass the route directly to the client-side functions:
import { useUser, getAccessToken, Auth0Provider } from "@auth0/nextjs-auth0/client";
// In your component
function MyComponent() {
const { user } = useUser({ route: "/tenant-a/auth/profile" });
const handleGetToken = async () => {
const token = await getAccessToken({
route: "/tenant-a/auth/access-token"
});
};
return <div>{user?.name}</div>;
}
// In your layout
export default function RootLayout({ children }) {
return (
<Auth0Provider profileRoute="/tenant-a/auth/profile">
{children}
</Auth0Provider>
);
}
Important
When using useUser with a custom route, ensure the Auth0Provider is configured with the same profileRoute to properly initialize the SWR cache.
Important
Updating the route paths will also require updating the Allowed Callback URLs and Allowed Logout URLs configured in the Auth0 Dashboard for your client.
Dynamic Application Base URLs
The SDK determines the application base URL in one of three ways, listed here from most to least specific:
- Static URL — a single string, used as-is.
- Allow-list — an array of allowed origins; the SDK matches the incoming request against the list.
- Unconstrained inference —
APP_BASE_URLomitted entirely; the base URL is inferred from the request host with no SDK-level origin check.
Allow-list (recommended for multiple origins per environment)
Some platforms assign more than one URL to the same deployment. For example, a Vercel app is reachable via both its custom domain and the platform-assigned *.vercel.app URL. Similarly, an application behind a load balancer may be reachable by IP address and by hostname.
In these cases, set APP_BASE_URL to a comma-separated list of all valid origins for that environment. The SDK matches the incoming request origin against the list and rejects any host not in it.
# .env.local
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_SECRET=
APP_BASE_URL=https://app.example.com,https://myapp.vercel.app
Or in code:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
// Custom domain and platform-assigned URL for the same deployment
appBaseUrl: ["https://app.example.com", "https://myapp.vercel.app"]
});
Each environment (development, staging, production) should have its own APP_BASE_URL configuration containing only the origins valid for that environment.
Static base URL
Use a single string when your application always runs on one origin:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
appBaseUrl: "https://app.example.com"
});
Unconstrained host inference
Omitting APP_BASE_URL entirely causes the SDK to infer the base URL from the incoming request host with no SDK-level origin check.
# .env.local
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_SECRET=
# APP_BASE_URL omitted — base URL inferred from the request host
Auth0 itself still enforces origin validation through the Allowed Callback URLs configured on your application in the Auth0 dashboard. When the SDK constructs the authorization request, the callback URL it sends is derived from the inferred host. Auth0 will reject any authorize request whose redirect_uri is not on the Allowed Callback URLs list, so every origin your application can be reached from must be registered there.
Prefer the allow-list approach unless the full set of valid origins cannot be known in advance.
Note
When relying on dynamic base URLs in production, the SDK enforces secure cookies. If you explicitly set AUTH0_COOKIE_SECURE=false, session.cookie.secure=false, or transactionCookie.secure=false, the SDK throws InvalidConfigurationError.
Testing helpers
generateSessionCookie
The generateSessionCookie helper can be used to generate a session cookie value for use during tests:
import { generateSessionCookie } from "@auth0/nextjs-auth0/testing";
const sessionCookieValue = await generateSessionCookie(
{
user: {
sub: "user_123"
},
tokenSet: {
accessToken: "at_123",
refreshToken: "rt_123",
expiresAt: 123456789
}
},
{
secret: process.env.AUTH0_SECRET!
}
);
Programmatically starting interactive login
Additionally to the ability to initialize the interactive login process by redirecting the user to the built-in auth/login endpoint,
the startInteractiveLogin method can also be called programmatically.
import { NextRequest } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export const GET = async (req: NextRequest) => {
return auth0.startInteractiveLogin();
};
Passing authorization parameters
There are 2 ways to customize the authorization parameters that will be passed to the /authorize endpoint when calling startInteractiveLogin programmatically. The first option is through static configuration when instantiating the client, like so:
export const auth0 = new Auth0Client({
authorizationParameters: {
scope: "openid profile email",
audience: "urn:custom:api"
}
});
The second option is by configuring authorizationParams when calling startInteractiveLogin:
import { NextRequest } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export const GET = async (req: NextRequest) => {
// Call startInteractiveLogin with optional parameters
return auth0.startInteractiveLogin({
authorizationParameters: {
scope: "openid profile email",
audience: "urn:custom:api"
}
});
};
The returnTo parameter
Redirecting the user after authentication
When calling startInteractiveLogin, the returnTo parameter can be configured to specify where you would like to redirect the user to after they have completed their authentication and have returned to your application.
import { NextRequest } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export const GET = async (req: NextRequest) => {
return auth0.startInteractiveLogin({
returnTo: "/dashboard"
});
};
Note
The URLs specified as returnTo parameters must be registered in your client's Allowed Callback URLs.
Getting access tokens for connections
You can retrieve an access token for a connection using the getAccessTokenForConnection() method, which accepts an object with the following properties:
connection: The federated connection for which an access token should be retrieved.login_hint: The optional login_hint parameter to pass to the/authorizeendpoint.
On the server (App Router)
On the server, the getAccessTokenForConnection() helper can be used in Server Routes, Server Actions and Server Components to get an access token for a connection.
Important
Server Components cannot set cookies. Calling getAccessTokenForConnection() in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted.
It is recommended to call getAccessTokenForConnection(req, res) in the middleware if you need to refresh the token in a Server Component as this will ensure the token is refreshed and correctly persisted.
For example:
import { NextResponse } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export async function GET() {
try {
const token = await auth0.getAccessTokenForConnection({
connection: "google-oauth2"
});
// call external API with token...
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
return NextResponse.json({
message: "Success!"
});
}
Upon further calls for the same provider, the cached value will be used until it expires.
On the server (Pages Router)
On the server, the getAccessTokenForConnection({}, req, res) helper can be used in getServerSideProps and API routes to get an access token for a connection, like so:
import type { NextApiRequest, NextApiResponse } from "next";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<{ message: string }>
) {
try {
const token = await auth0.getAccessTokenForConnection(
{ connection: "google-oauth2" },
req,
res
);
} catch (err) {
// err will be an instance of AccessTokenError if an access token could not be obtained
}
res.status(200).json({ message: "Success!" });
}
Middleware
In middleware, the getAccessTokenForConnection({}, req, res) helper can be used to get an access token for a connection, like so:
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
const accessToken = await auth0.getAccessTokenForConnection(
{ connection: "google-oauth2" },
request,
authRes
);
// the headers from the auth middleware should always be returned
return authRes;
}
Important
The request and response objects must be passed as a parameters to the getAccessTokenForConnection({}, request, response) method when called from a middleware to ensure that the refreshed access token can be accessed within the same request.
If you are using the Pages Router and are calling the getAccessTokenForConnection method in both the middleware and an API Route or getServerSideProps, it's recommended to propagate the headers from the middleware, as shown below. This will ensure that calling getAccessTokenForConnection in the API Route or getServerSideProps will not result in the access token being refreshed again.
import { NextRequest, NextResponse } from "next/server";
import { auth0 } from "./lib/auth0"; // Adjust path if your auth0 client is elsewhere
export async function middleware(request: NextRequest) {
const authRes = await auth0.middleware(request);
if (request.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}
const session = await auth0.getSession(request);
if (!session) {
// user is not authenticated, redirect to login page
return NextResponse.redirect(
new URL("/auth/login", request.nextUrl.origin)
);
}
const accessToken = await auth0.getAccessTokenForConnection(
{ connection: "google-oauth2" },
request,
authRes
);
// create a new response with the updated request headers
const resWithCombinedHeaders = NextResponse.next({
request: {
headers: request.headers
}
});
// set the response headers (except set-cookie) from the auth response
authRes.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
resWithCombinedHeaders.headers.set(key, value);
}
});
// append the response headers (set-cookie) from the auth response
authRes.headers.getSetCookie().forEach(setCookie => {
resWithCombinedHeaders.headers.append('set-cookie', setCookie);
});
// the headers from the auth middleware should always be returned
return resWithCombinedHeaders;
}
Custom Token Exchange
Custom Token Exchange (CTE) allows you to exchange external tokens (from legacy systems, third-party identity providers, or custom token services) for Auth0 access tokens. This implements RFC 8693 (OAuth 2.0 Token Exchange).
When to Use
- Legacy System Migration: Exchange tokens from legacy auth systems for Auth0 tokens
- Third-Party Federation: Convert tokens from external identity providers
- Token Mediation: Bridge between different token ecosystems in your architecture
Basic Usage
import { auth0 } from "@/lib/auth0";
export async function exchangeExternalToken(legacyToken: string) {
try {
const result = await auth0.customTokenExchange({
subjectToken: legacyToken,
subjectTokenType: "urn:acme:legacy-token",
audience: "https://api.example.com"
});
return {
accessToken: result.accessToken,
expiresIn: result.expiresIn,
tokenType: result.tokenType
};
} catch (error) {
if (error instanceof CustomTokenExchangeError) {
console.error(`Exchange failed: ${error.code}`, error.message);
}
throw error;
}
}
With Organization
When exchanging tokens for organization-scoped access:
const result = await auth0.customTokenExchange({
subjectToken: externalToken,
subjectTokenType: "urn:partner:sso-token",
organization: "org_abc123",
scope: "read:data write:data"
});
With Actor Token (Delegation)
For delegation scenarios where a service or agent acts on behalf of a user (RFC 8693 §4.1). The act claim in the returned ID token identifies the acting party and is automatically decoded and returned on the response:
const result = await auth0.customTokenExchange({
subjectToken: userToken,
subjectTokenType: "urn:acme:user-token",
actorToken: agentToken,
actorTokenType: "https://idp.example.com/token-type/agent",
audience: "https://downstream-api.example.com"
});
// act claim decoded from the ID token — set via Auth0 Action using api.authentication.setActor()
console.log(result.act);
// { sub: "agent|abc123" }
The act claim is also accessible on session.user.act when present in a session-creating flow (e.g. standard OIDC login with an Action that calls api.authentication.setActor()):
const session = await auth0.getSession();
console.log(session?.user?.act);
// { sub: "agent|abc123" }
Note: Auth0 suppresses the refresh token when
actor_tokenis present in the request.result.refreshTokenwill beundefinedin delegation flows — this is expected behaviour, not an error.
Error Handling
import {
CustomTokenExchangeError,
CustomTokenExchangeErrorCode
} from "@auth0/nextjs-auth0/errors";
try {
const result = await auth0.customTokenExchange({
subjectToken: token,
subjectTokenType: "urn:acme:token"
});
} catch (error) {
if (error instanceof CustomTokenExchangeError) {
switch (error.code) {
case CustomTokenExchangeErrorCode.MISSING_SUBJECT_TOKEN:
// Handle missing subject token
break;
case CustomTokenExchangeErrorCode.INVALID_SUBJECT_TOKEN_TYPE:
// Handle invalid token type format
break;
case CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN_TYPE:
// Handle missing actor token type when actor token provided
break;
case CustomTokenExchangeErrorCode.INVALID_ACTOR_TOKEN_TYPE:
// Handle invalid actor token type format
break;
case CustomTokenExchangeErrorCode.EXCHANGE_FAILED:
// Handle server-side exchange failure
console.error("Exchange failed:", error.cause);
break;
}
}
}
Token Type Requirements
The subjectTokenType (and actorTokenType if used) must:
- Be 10-100 characters in length (per Auth0 CTE Profiles Management API)
- Be a valid URI (starting with
urn:orhttps://orhttp://)
Valid examples:
urn:acme:legacy-tokenurn:partner:sso-token:v1https://example.com/token-types/external
Note: Reserved namespaces (e.g.,
urn:ietf:,urn:auth0:) are validated by Auth0 when creating CTE profiles via the Management API.
Limitations
Important
Custom Token Exchange has specific constraints you should be aware of (see Auth0 Custom Token Exchange documentation for details):
- Server-side only: Requires
client_secret, cannot be used in browser - No Auth0 session created: Returns tokens only, does not establish an Auth0 session
- No token caching: Tokens are not stored in the user's session; each call performs a new exchange
- MFA not supported: Exchange fails if the user's policy requires MFA
- Rate limiting: Subject to Auth0's token exchange rate limits
DPoP Support
When DPoP is enabled in your Auth0Client configuration, custom token exchange automatically uses DPoP-bound tokens:
const auth0 = new Auth0Client({
// ... other config
dPoPOptions: {
enabled: true
}
});
// DPoP proof will be automatically included
const result = await auth0.customTokenExchange({
subjectToken: externalToken,
subjectTokenType: "urn:acme:external-token"
});
// result.tokenType will be "DPoP"
Customizing Auth Handlers
Authentication routes (/auth/login, /auth/logout, /auth/callback) are handled automatically by the middleware. You can intercept these routes in your middleware to run custom logic before the auth handlers execute.
This approach allows you to:
- Run custom code before authentication actions (logging, analytics, validation)
- Modify the response (set cookies, headers, etc.)
- Implement custom redirects or early returns when needed
- Add business logic around authentication flows
- Maintain compatibility with existing tracking and analytics systems
The middleware-based approach provides the same level of control as v3's custom handlers while working seamlessly with v4's automatic route handling.
Run custom code before Auth Handlers
Following example shows how to run custom logic before the response of logout handler is returned:
export async function middleware(request) {
// prepare NextResponse object from auth0 middleware
const authRes = await auth0.middleware(request);
// The following interceptUrls can be used:
// "/auth/login" : intercept login auth handler
// "/auth/logout" : intercept logout auth handler
// "/auth/callback" : intercept callback auth handler
// "/your/login/returnTo/url" : intercept redirect after login, this is the login returnTo url
// "/your/logout/returnTo/url" : intercept redirect after logout, this is the logout returnTo url
const interceptUrl = "/auth/logout";
// intercept auth handler
if (request.nextUrl.pathname === interceptUrl) {
// do custom stuff
console.log("Pre-logout code");
// Example: Set a cookie
authRes.cookies.set("myCustomCookie", "cookieValue", { path: "/" });
// Example: Set another cookie with options
authRes.cookies.set({
name: "anotherCookie",
value: "anotherValue",
httpOnly: true,
path: "/"
});
// Example: Delete a cookie
// authRes.cookies.delete('cookieNameToDelete');
// you can also do an early return here with your own NextResponse object
// return NextResponse.redirect(new URL('/custom-logout-page'));
}
// return the original auth0-handled NextResponse object
return authRes;
}
Run code after callback
Please refer to onCallback for details on how to run code after callback.
Next.js 16 Compatibility
To support Next.js 16, rename your middleware.ts file to proxy.ts, and rename the exported function from middleware to proxy.
All existing examples and helpers (getSession, updateSession, getAccessToken, etc.) will continue to work without any other changes.
- // middleware.ts
- export async function middleware(request: NextRequest) {
- return auth0.middleware(request);
- }
+ // proxy.ts
+ export async function proxy(request: Request) {
+ return auth0.middleware(request);
+ }
Note
Next.js 16 still supports the traditional middleware.ts file for Edge runtime use-cases,
but it is now considered deprecated. Future versions of Next.js may remove Edge-only middleware,
so it’s recommended to migrate to proxy.ts for long-term compatibility.
For more details, see the official Next.js documentation:
➡️ Upgrading to Next 16 Middleware
➡️ Proxy.ts Conventions
Multi-Factor Authentication (MFA)
Note
Multi Factor Authentication support via SDKs is currently in Early Access.
The SDK provides comprehensive MFA client APIs to manage multi-factor authentication for your users. The MFA client is accessible via the mfa property on both server and client Auth0 instances.
Setup & Configuration
Before using MFA APIs, configure your Auth0 tenant:
- Enable MFA in Auth0 Dashboard > Security > Multi-factor Auth
- Configure Factors: Enable OTP, SMS, Email, or Push Notification
- Set Tenant Policy to "Adaptive" or "Never" (see MFA Tenant Configuration)
- Configure MFA Actions to conditionally enforce MFA for specific resources
Configuration
Configure MFA token TTL via options or environment variable:
// lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
mfaContextTtl: 600 // 10 minutes in seconds
});
# .env.local
AUTH0_MFA_CONTEXT_TTL=600
Default TTL is 300 seconds (5 minutes), matching Auth0's mfa_token expiration.
Handling MfaRequiredError
When you request an Access Token for a resource that requires MFA, Auth0 will return a 403 Forbidden. The SDK automatically catches this and throws an MfaRequiredError containing the mfaToken needed to resolve the challenge.
mfa_required Response:
{
"error": "mfa_required",
"error_description": "Multifactor authentication required",
"mfa_token": "Fe26...encoded_token"
}
Add a catch handler for MfaRequiredError around getAccessToken call:
try {
const { token } = await getAccessToken({ audience: "https://api.example.com" });
} catch (error) {
if (error instanceof MfaRequiredError) {
// MFA logic here
// You can pass the `error.mfa_token` to SDK MFA methods
// Example, redirect to MFA challenge page that contains MFA handling logic
redirect(`/mfa?token=${error.mfa_token}`);
}
throw error;
}
Accessing the MFA API
The MFA API is accessible on both the server and the client to manage authenticators and perform verification.
On the Server:
The MFA API is available via the mfa property of your Auth0Client instance.
// lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client();
// Usage in Route Handler or Server Action
const authenticators = await auth0.mfa.getAuthenticators({ mfaToken });
On the Client:
The MFA API is available as a named export mfa from the client entry point.
// components/mfa-form.tsx
import { mfa } from "@auth0/nextjs-auth0/client";
// Usage in client component
await mfa.verify({ mfaToken, otp });
Getting Authenticators
List all enrolled authenticators for the current user:
const authenticators = await auth0.mfa.getAuthenticators({ mfaToken });
Enrollment
Enroll new authenticators for MFA. Support includes OTP (TOTP apps), SMS, Email, and Push Notification.
OTP (Authenticator App)
// Returns secret, barcodeUri for QR code
const enrollment = await auth0.mfa.enroll({
mfaToken,
authenticatorTypes: ["otp"]
});
SMS
const enrollment = await auth0.mfa.enroll({
mfaToken,
authenticatorTypes: ["oob"],
oobChannels: ["sms"],
phoneNumber: "+15555555555"
});
const enrollment = await auth0.mfa.enroll({
mfaToken,
authenticatorTypes: ["oob"],
oobChannels: ["email"],
email: "user@example.com"
});
Push Notification
const enrollment = await auth0.mfa.enroll({
mfaToken,
authenticatorTypes: ["oob"],
oobChannels: ["auth0"]
});
Challenge
Initiate an MFA challenge for OOB authenticators (SMS/Email/Push). OTP authenticators do not require explicit challenge.
// Returns oobCode and bindingMethod
const challenge = await auth0.mfa.challenge({
mfaToken,
challengeType: "oob",
authenticatorId: "sms|..."
});
Verify
Verify MFA with OTP code, OOB code, or recovery code.
OTP Verification
await auth0.mfa.verify({
mfaToken,
otp: "123456"
});
OOB Verification (SMS/Email/Push)
await auth0.mfa.verify({
mfaToken,
oobCode: challenge.oobCode,
bindingCode: "123456" // User input
});
Recovery Code Verification
await auth0.mfa.verify({
mfaToken,
recoveryCode: "ABCD-EFGH-IJKL-MNOP"
});
Complete Flow Examples
For complete implementation guides and best practices, refer to the official Auth0 documentation:
MFA Tenant Configuration
The SDK relies on background token refreshes to maintain user sessions. For these non-interactive requests to succeed, configure your MFA policies to allow refresh_token exchanges without immediate user challenge.
Note
Enforcing "Always" or "All Applications" in your global Tenant MFA Policy will block background token refreshes, as they cannot satisfy an interactive MFA challenge.
Recommended Configuration: Set Tenant MFA Policy to "Adaptive" or "Never".
Example Action Code:
exports.onExecutePostLogin = async (event, api) => {
// Only trigger on refresh_token grant (step-up)
if (event.request?.body?.grant_type == "refresh_token") {
if (event.user.enrolledFactors.length) {
// User has factors enrolled - challenge
api.authentication.challengeWithAny([
{ type: 'otp' },
{ type: 'phone' },
{ type: 'push-notification' },
{ type: 'email' },
{ type: 'recovery-code' }
]);
} else {
// No factors enrolled - prompt enrollment
api.authentication.enrollWithAny([
{ type: 'otp'},
{ type: 'phone'},
{ type: 'push-notification' }
]);
}
}
};
MFA Error Handling
The SDK provides typed error classes for all MFA operations:
| Error Class | Code | When Thrown | Example |
|---|---|---|---|
MfaRequiredError | mfa_required | Token refresh requires MFA step-up | Accessing protected API |
MfaGetAuthenticatorsError | Various | Failed to list authenticators | Invalid/expired token |
MfaEnrollmentError | Various | Enrollment failed | Unsupported factor type |
MfaDeleteAuthenticatorError | Various | Delete failed | Authenticator not found |
MfaChallengeError | Various | Challenge failed | Invalid authenticator ID |
MfaVerifyError | invalid_grant | Verification failed | Invalid OTP code |
MfaTokenNotFoundError | mfa_token_not_found | No MFA context for token | Token not in session |
MfaTokenExpiredError | mfa_token_expired | Token TTL exceeded | Context expired |
MfaTokenInvalidError | mfa_token_invalid | Token tampered or wrong secret | Decryption failed |
Reactive MFA Step-Up (Popup)
Overview
The SDK supports reactive MFA step-up via a browser popup using Auth0 Universal Login. When an API call fails with mfa_required, the client-side mfa.challengeWithPopup() method opens a popup window where the user completes MFA through Auth0's Universal Login. After completion, the token is cached in the server-side session and returned directly to the caller — no full-page redirect required.
This is useful for applications that need to protect specific actions (e.g., transferring funds, changing settings) with MFA without disrupting the user's current page state.
Flow summary:
- App calls an API that requires MFA → receives
MfaRequiredError - App calls
mfa.challengeWithPopup({ audience })→ popup opens - User completes MFA in the popup via Auth0 Universal Login
- Popup sends result back via
postMessage→ popup auto-closes - SDK retrieves the cached token from the server session
challengeWithPopup()resolves with the access token
Basic Usage
'use client';
import { mfa, getAccessToken } from '@auth0/nextjs-auth0/client';
import { MfaRequiredError } from '@auth0/nextjs-auth0/errors';
import { useState } from 'react';
export function ProtectedAction() {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
async function handleAction() {
try {
// 1. Try to get an access token for the protected API
const token = await getAccessToken({
audience: 'https://api.example.com',
scope: 'read:sensitive'
});
// 2. Use the token to call your API
const res = await fetch('https://api.example.com/sensitive', {
headers: { Authorization: `Bearer ${token}` }
});
setResult(await res.json());
} catch (err) {
if (err instanceof MfaRequiredError) {
try {
// 3. MFA required — trigger popup step-up
const { token } = await mfa.challengeWithPopup({
audience: 'https://api.example.com',
scope: 'read:sensitive'
});
// 4. Retry with the step-up token
const res = await fetch('https://api.example.com/sensitive', {
headers: { Authorization: `Bearer ${token}` }
});
setResult(await res.json());
} catch (popupErr) {
setError(popupErr.message);
}
} else {
setError(err.message);
}
}
}
return (
<div>
<button onClick={handleAction}>Perform Sensitive Action</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
</div>
);
}
Handling MfaRequiredError from Client Components
The client-side getAccessToken() helper automatically detects 403 responses with error: "mfa_required" and throws MfaRequiredError. This allows you to use instanceof checks to trigger the popup flow:
import { getAccessToken } from '@auth0/nextjs-auth0/client';
import { MfaRequiredError } from '@auth0/nextjs-auth0/errors';
try {
const token = await getAccessToken({ audience: 'https://api.example.com' });
} catch (err) {
if (err instanceof MfaRequiredError) {
// Trigger popup MFA step-up
const { token } = await mfa.challengeWithPopup({
audience: 'https://api.example.com'
});
}
}
Note
The MfaRequiredError detection works for both server-side and client-side getAccessToken() calls. On the client, it is reconstructed from the 403 JSON response returned by the /auth/access-token endpoint.
Configuration Options
challengeWithPopup() accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
audience | string | (required) | Target API audience identifier |
scope | string | 'openid profile email' | Space-separated scopes for the token |
acr_values | string | 'http://schemas.openid.net/pape/policies/2007/06/multi-factor' | ACR values sent to Auth0 for step-up policy |
returnTo | string | '/' | Return URL (used internally by the OAuth flow) |
timeout | number | 60000 | Popup timeout in milliseconds |
popupWidth | number | 400 | Popup window width in pixels |
popupHeight | number | 600 | Popup window height in pixels |
Example with custom options:
const { token } = await mfa.challengeWithPopup({
audience: 'https://api.example.com',
scope: 'openid profile email transfer:funds',
timeout: 120000, // 2 minutes
popupWidth: 500,
popupHeight: 700
});
Note
Popup timeout is configured per-call only. There is no server-side configuration option or environment variable for this — timeout is a client-side runtime concern. If you need a consistent default across your app, define an application-level constant and pass it to every call.
CSP Nonce Support
If your application uses a strict Content Security Policy that blocks inline scripts, configure a CSP nonce on the server-side Auth0Client:
// lib/auth0.ts
import { Auth0Client } from '@auth0/nextjs-auth0/server';
export const auth0 = new Auth0Client({
cspNonce: 'your-generated-nonce'
});
The nonce is injected into the <script> tag of the popup callback HTML response, making it compliant with script-src 'nonce-...' CSP policies.
Important
The nonce must contain only base64 characters (A-Za-z0-9+/=-_). Invalid characters will throw an InvalidConfigurationError.
Note
The cspNonce is set at Auth0Client construction time and remains static for the lifetime of the instance. Since Auth0Client is typically a singleton, this means the same nonce is reused across requests. This still provides protection over 'unsafe-inline' (the script must know the nonce), but is weaker than per-request nonce rotation. If your security policy requires per-request nonces, you would need to create the Auth0Client per-request or use middleware to inject a fresh nonce via a custom header.
If you do not configure a cspNonce and your CSP blocks inline scripts, the popup will complete the MFA flow but the parent window will never receive the postMessage. This manifests as a PopupTimeoutError after the configured timeout.
Error Handling
challengeWithPopup() can throw several typed errors. Handle them to provide appropriate user feedback:
import { mfa } from '@auth0/nextjs-auth0/client';
import {
PopupBlockedError,
PopupCancelledError,
PopupTimeoutError,
PopupInProgressError,
ExecutionContextError
} from '@auth0/nextjs-auth0/errors';
try {
const { token } = await mfa.challengeWithPopup({
audience: 'https://api.example.com'
});
} catch (err) {
if (err instanceof PopupBlockedError) {
// Browser blocked the popup — prompt user to allow popups
alert('Please allow popups for this site and try again.');
} else if (err instanceof PopupCancelledError) {
// User closed the popup before completing MFA
console.log('MFA cancelled by user.');
} else if (err instanceof PopupTimeoutError) {
// Popup did not complete within the timeout
console.log('MFA timed out. Please try again.');
} else if (err instanceof PopupInProgressError) {
// Another popup is already open
console.log('Please complete the current MFA prompt first.');
} else if (err instanceof ExecutionContextError) {
// Called from server-side code (SSR, middleware)
console.error('challengeWithPopup() can only be called in browser context.');
} else {
// AccessTokenError or other errors
console.error('MFA failed:', err.message);
}
}
Multiple Custom Domains (MCD)
Multiple Custom Domains (MCD) enables a single @auth0/nextjs-auth0 instance to authenticate users against different Auth0 custom domains on the same tenant. This is useful for:
- B2C Multi-Brand: Multiple branded auth domains (
auth.brand1.com,auth.brand2.com) on a single Auth0 tenant - B2B SaaS: Dynamic per-customer domains (
auth.customer-a.com,auth.customer-b.com) resolved at runtime - Domain Migration: Both old and new domains valid simultaneously during transition
The SDK operates in two modes:
| Mode | Configuration | Behavior |
|---|---|---|
| Static (default) | domain: "example.auth0.com" | Single domain, zero overhead, existing behavior preserved |
| Resolver | domain: (ctx) => string | Per-request domain resolution via DomainResolver function |
Static Mode (Default)
Static mode requires no changes. Existing applications continue to work as-is:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
// Static mode — single domain, same as pre-MCD behavior
export const auth0 = new Auth0Client({
domain: "example.us.auth0.com"
});
Or via environment variable:
AUTH0_DOMAIN=example.us.auth0.com
Resolver Mode
Basic Setup
Pass a function as the domain option to enable resolver mode:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
export const auth0 = new Auth0Client({
domain: ({ headers }) => {
const host = headers.get("host") ?? "";
if (host.startsWith("brand1.")) return "auth.brand1.com";
if (host.startsWith("brand2.")) return "auth.brand2.com";
return "auth.default.com";
}
});
Important
In resolver mode, the SDK automatically enforces the openid scope to ensure the callback contains an ID token with an iss claim for issuer validation.
DomainResolver Signature
type DomainResolver = (config: {
headers: Headers;
url?: URL;
}) => Promise<string> | string;
| Parameter | Type | Description |
|---|---|---|
config.headers | Headers | Request headers from the current context |
config.url | URL | undefined | Request URL when available. undefined in Server Components/Actions |
Context availability by Next.js environment:
| Environment | headers | url |
|---|---|---|
| Middleware / Route Handlers | From NextRequest | NextRequest.nextUrl |
Pages Router (getServerSideProps, API Routes) | From IncomingMessage | Constructed from req.url + Host |
| App Router Server Components / Server Actions | Via headers() from next/headers | undefined |
Return value: The Auth0 custom domain hostname (e.g., "auth.brand1.com"). Must throw on failure — the SDK wraps thrown errors in DomainResolutionError.
Use Cases
B2C Multi-Brand
Multiple branded login experiences on a single tenant. Each brand has its own Auth0 custom domain.
export const auth0 = new Auth0Client({
domain: ({ headers }) => {
const host = headers.get("host") ?? "";
const brandDomains: Record<string, string> = {
"brand1.example.com": "auth.brand1.com",
"brand2.example.com": "auth.brand2.com",
};
const domain = brandDomains[host];
if (!domain) throw new Error(`Unknown brand host: ${host}`);
return domain;
}
});
B2B SaaS with Database Lookup
Resolve the Auth0 domain from a tenant database at runtime:
export const auth0 = new Auth0Client({
domain: async ({ headers }) => {
const tenantId = headers.get("x-tenant-id");
if (!tenantId) throw new Error("Missing x-tenant-id header");
const tenant = await db.tenants.findUnique({ where: { id: tenantId } });
if (!tenant?.auth0Domain) {
throw new Error(`No Auth0 domain configured for tenant: ${tenantId}`);
}
return tenant.auth0Domain;
}
});
URL-Based Routing
Use the request URL to determine the domain (available in Middleware and Route Handlers):
export const auth0 = new Auth0Client({
domain: ({ url }) => {
const subdomain = url?.hostname?.split(".")[0];
switch (subdomain) {
case "us": return "auth-us.example.com";
case "eu": return "auth-eu.example.com";
default: return "auth.example.com";
}
}
});
Note
url is undefined in Server Components and Server Actions. If your resolver depends on the URL, fall back to parsing headers.get("host") or use a cookie/header-based approach for those contexts.
Discovery Cache Configuration
The SDK caches OIDC discovery metadata (.well-known/openid-configuration) to minimize network requests. Configure via the discoveryCache option:
export const auth0 = new Auth0Client({
domain: myDomainResolver,
discoveryCache: {
ttl: 300, // Cache TTL in seconds (default: 600)
maxEntries: 50 // Max cached issuers (default: 100, LRU eviction)
}
});
| Option | Type | Default | Description |
|---|---|---|---|
ttl | number | 600 | Time-to-live for cached metadata (seconds) |
maxEntries | number | 100 | Maximum issuers to cache (LRU eviction when exceeded) |
The cache also deduplicates in-flight requests — multiple concurrent requests for the same domain share a single discovery fetch.
MCD with Dynamic appBaseUrl
domain and appBaseUrl are orthogonal:
domainresolves which Auth0 custom domain to authenticate againstappBaseUrlresolves your application's own origin forredirect_uriconstruction
All appBaseUrl modes compose with MCD resolver mode:
// B2C Multi-Brand: single app domain, multiple Auth0 domains
export const auth0 = new Auth0Client({
appBaseUrl: "https://app.example.com",
domain: ({ headers }) => {
// Resolve Auth0 domain from a cookie or header
const brand = headers.get("x-brand") ?? "default";
return `auth.${brand}.example.com`;
}
});
// B2B Multi-Tenant: dynamic app origins + dynamic Auth0 domains
export const auth0 = new Auth0Client({
appBaseUrl: [
"https://tenant-a.example.com",
"https://tenant-b.example.com"
],
domain: async ({ headers }) => {
const host = headers.get("host") ?? "";
return await resolveTenantDomain(host);
}
});
// Fully Dynamic: inferred app origin + dynamic Auth0 domains
export const auth0 = new Auth0Client({
// appBaseUrl omitted — inferred from request host
domain: async ({ headers }) => {
return await lookupAuth0Domain(headers);
}
});
Important
When using MCD with dynamic appBaseUrl, ensure all resulting callback and logout URLs are registered in your Auth0 application's Allowed Callback URLs and Allowed Logout URLs.
Warning
If your application is served through multiple hostnames (e.g., a reverse proxy, multi-brand fronts, or preview deployments), a static APP_BASE_URL / appBaseUrl string will force all redirect_uri values to that single origin — regardless of which hostname the user actually accessed. This causes state parameter mismatches because the transaction cookie is set on one origin but the callback arrives on another.
| Scenario | Recommended appBaseUrl |
|---|---|
| Single app hostname, multiple Auth0 domains | Static string: "https://app.example.com" |
| Multiple app hostnames, multiple Auth0 domains | Allow-list: ["https://brand1.com", "https://brand2.com"] |
| Fully dynamic app hostnames | Omit appBaseUrl entirely (SDK infers from x-forwarded-proto / x-forwarded-host headers) |
If you are migrating to MCD resolver mode and your app is reachable on multiple origins, remove or replace your static APP_BASE_URL with an allow-list or omit it to enable per-request inference.
Session Domain Isolation
In resolver mode, sessions are bound to the domain that created them. The SDK stores domain and issuer metadata in the session's internal state:
SessionData.internal.mcd = {
domain: "auth.brand1.com",
issuer: "https://auth.brand1.com/"
}
Behavior:
- When reading a session (
getSession,getAccessToken, middleware), the SDK resolves the current domain and compares it tosession.internal.mcd.domain - If domains differ, the session is treated as not found (returns
null) and aSessionDomainMismatchErroris returned internally — the user must re-authenticate - If domain resolution itself fails,
DomainResolutionErroris thrown - In static mode, no domain check is performed (backward compatible)
- Pre-MCD sessions (created before MCD support, without
internal.mcdmetadata) are automatically backfilled with the current domain and issuer on first access. This is a fail-open strategy: the session is accepted and tagged with the current domain. If the user later accesses the app on a different MCD domain, the backfilled session will be rejected due to domain mismatch
This prevents a session created via auth.brand1.com from being used when the request resolves to auth.brand2.com, even if cookies are shared across subdomains.
Error Handling
MCD introduces three new error classes, all extending SdkError:
import {
DomainResolutionError,
DomainValidationError,
IssuerValidationError,
SessionDomainMismatchError,
McdBackchannelLogoutError
} from "@auth0/nextjs-auth0/server";
| Error | Code | When Thrown |
|---|---|---|
DomainResolutionError | domain_resolution_error | Resolver throws or returns empty string |
DomainValidationError | domain_validation_error | Resolved domain is not a valid hostname (IP, localhost, IPv6, has path/port) |
IssuerValidationError | issuer_validation_error | ID token iss claim doesn't match the expected issuer during callback |
SessionDomainMismatchError | session_domain_mismatch | Session was created for a different MCD domain than the current request resolves to |
McdBackchannelLogoutError | backchannel_logout_error | Backchannel logout request fails in MCD context (e.g., domain resolution fails during logout) |
Handling resolver errors:
import { auth0 } from "@/lib/auth0";
import { DomainResolutionError } from "@auth0/nextjs-auth0/server";
export default async function Page() {
try {
const session = await auth0.getSession();
// ...
} catch (error) {
if (error instanceof DomainResolutionError) {
// Domain resolver failed — possibly missing header or DB error
console.error("Domain resolution failed:", error.message, error.cause);
// Show fallback or redirect
}
throw error;
}
}
Error reference:
| Error Class | Code | When Thrown |
|---|---|---|
PopupBlockedError | popup_blocked | Browser blocked window.open() |
PopupCancelledError | popup_cancelled | User closed the popup window |
PopupTimeoutError | popup_timeout | Popup did not complete within timeout |
PopupInProgressError | popup_in_progress | Another challengeWithPopup() call is active |
ExecutionContextError | invalid_execution_context | Called outside browser context (SSR/middleware) |
AccessTokenError | Various | Token retrieval failed after popup completed |
Security Considerations
- Same-origin postMessage: The popup listener only accepts messages from
window.location.origin. Cross-origin messages are silently ignored. - No tokens in postMessage: The popup's
postMessagepayload contains only{ sub, email }metadata — never raw access tokens. Tokens remain server-side in the encrypted session cookie. - PKCE: The popup flow uses the same PKCE-based authorization code exchange as standard login. No security downgrade.
- State encryption: The
returnStrategyflag is stored in the encrypted transaction cookie alongside other OAuth state (AES-256-GCM). - XSS prevention: The callback HTML uses
JSON.stringify()with<escaping (\u003c) to prevent script injection via user-controlled values.
Known Limitations
| Limitation | Details |
|---|---|
| One popup at a time | Only one challengeWithPopup() call is allowed concurrently. A second call throws PopupInProgressError regardless of audience. |
| Same-origin only | The postMessage validation requires same-origin. Cross-origin popup flows are not supported. |
| Browser popup policies | Most browsers block popups unless triggered by a direct user action (click handler). Ensure challengeWithPopup() is called within a user-initiated event handler. |
beforeSessionSaved idempotency | The beforeSessionSaved hook runs again when the popup token is merged into the existing session. Ensure your hook is idempotent when using popup flows. |
| Session cookie size | Each cached MRRT token increases session cookie size. For applications with many audiences, consider using a database session store. |
Handling issuer validation errors:
IssuerValidationError is thrown during the authentication callback when the ID token's issuer doesn't match the transaction's expected issuer. This indicates a potential cross-domain token confusion attack or misconfiguration.
export const auth0 = new Auth0Client({
domain: myResolver,
onCallback: async (error, context, session) => {
if (error instanceof IssuerValidationError) {
console.error(
`Issuer mismatch: expected ${error.expectedIssuer}, got ${error.actualIssuer}`
);
return new NextResponse("Authentication failed", { status: 403 });
}
// Handle other errors...
}
});
Security Considerations
DomainResolver Patterns
When implementing a DomainResolver, always prioritize the url parameter over headers for hostname resolution, as the url is parsed by Next.js from the actual request and is not affected by spoofed x-forwarded-* headers.
Preferred pattern: Use the url parameter
The url parameter is parsed by Next.js and represents the actual request URL, making it resistant to header spoofing attacks:
const auth0 = new Auth0Client({
domain: ({ url }) => {
const hostname = url?.hostname ?? "default.example.com";
const ALLOWED_HOSTS = ["brand1.example.com", "brand2.example.com"];
if (!ALLOWED_HOSTS.includes(hostname)) {
throw new Error(`Untrusted hostname: ${hostname}`);
}
return hostname.startsWith("brand1.") ? "auth.brand1.com" : "auth.brand2.com";
}
});
If using headers: Always validate against a known allow-list
When url is unavailable (e.g., in Server Components or Server Actions), you must fall back to header-based resolution with strict validation:
const auth0 = new Auth0Client({
domain: ({ headers }) => {
const host = headers.get("host") ?? "";
const ALLOWED_HOSTS = ["brand1.example.com", "brand2.example.com"];
if (!ALLOWED_HOSTS.includes(host)) {
throw new Error(`Untrusted host header: ${host}`);
}
return host.startsWith("brand1.") ? "auth.brand1.com" : "auth.brand2.com";
}
});
Warning: x-forwarded- headers*
In self-hosted deployments, the x-forwarded-host and x-forwarded-proto headers can be set by attackers if your reverse proxy is not properly configured. These headers are not cryptographically signed and bypass application validation. Use the url parameter whenever possible, as it's parsed by Next.js from the actual request URL and is not affected by spoofed forwarding headers.
X-Forwarded Headers Trust Model
When deploying behind a reverse proxy, the SDK's inferBaseUrlFromRequest() uses x-forwarded-host and x-forwarded-proto headers to determine the application's base URL. These headers are trusted by default.
Risk: In self-hosted deployments without proper reverse proxy configuration, an attacker can set these headers to manipulate the resolved base URL, potentially enabling:
- Open redirects via the
returnToparameter after authentication - Cookie domain misalignment in allow-list mode
Who is affected:
- Vercel deployments: NOT affected (Vercel strips/validates these headers)
- Self-hosted behind properly configured reverse proxy: NOT affected (proxy overwrites headers)
- Self-hosted WITHOUT reverse proxy or with misconfigured proxy: AFFECTED
Mitigations:
- Always set a static
appBaseUrlin production when possible — this bypasses header inference entirely - When using
appBaseUrlas an array (allow-list mode), the SDK validates the inferred URL against the allow-list, limiting the attack surface - Ensure your reverse proxy sets
x-forwarded-hostandx-forwarded-protoand does not pass through client-supplied values - When using a DomainResolver, prefer the
urlparameter over raw headers (as shown in the DomainResolver Patterns section above)
// SAFE: Static appBaseUrl bypasses header inference
const auth0 = new Auth0Client({
appBaseUrl: "https://myapp.example.com",
// ...
});
// SAFE: Allow-list validates inferred URL
const auth0 = new Auth0Client({
appBaseUrl: [
"https://brand1.example.com",
"https://brand2.example.com"
],
// ...
});
Resolver Input Validation
The DomainResolver receives request headers and optional URL. The SDK validates the resolver's output (domain hostname format), but the resolver is responsible for its own input validation:
// GOOD: Validate against an allowlist
const auth0 = new Auth0Client({
domain: ({ headers }) => {
const host = headers.get("host") ?? "";
const allowed = ["brand1.example.com", "brand2.example.com"];
const match = allowed.find(d => host.includes(d));
if (!match) throw new Error(`Untrusted host: ${host}`);
return domainMap[match];
}
});
// BAD: Trusting raw header input without validation
const auth0 = new Auth0Client({
domain: ({ headers }) => {
return headers.get("x-auth-domain")!; // Never trust raw headers!
}
});
Domain Validation
The SDK rejects domains that are:
- IPv4 or IPv6 addresses
localhostor.localdomains (unlessallowInsecureRequestsis enabled for dev)- Hostnames containing paths, ports, or non-HTTPS schemes
Issuer Validation
During the authentication callback, the SDK performs dual-layer issuer validation:
- Uses the transaction's
originDomainfor OIDC discovery (not the currently resolved domain) - Explicitly compares the ID token
issclaim against the storedoriginIssuer
This prevents cross-domain token confusion where an attacker redirects the callback to a different domain.
Backward Compatibility
MCD is fully backward compatible:
- Existing apps: No changes required.
domain: "string"andAUTH0_DOMAINenv var work as before - Pre-MCD sessions: Sessions without
internal.mcdmetadata continue to work in static mode. In resolver mode, pre-MCD sessions are rejected (fail-closed) to prevent domain confusion - In-flight transactions: Transaction cookies without
originDomain/originIssuerare handled gracefully during SDK upgrades. The extra issuer validation is skipped for legacy transactions - Type safety:
domainacceptsstring | DomainResolver— existing string configurations are unchanged
Debugging MCD Issues
Common issues and solutions:
| Symptom | Cause | Solution |
|---|---|---|
DomainResolutionError on every request | Resolver throws or returns empty | Check resolver logic and available headers |
Session returns null unexpectedly | Domain mismatch between login and current request | Verify resolver returns the same domain for login and subsequent requests |
IssuerValidationError during callback | Token issued by different domain than expected | Ensure the resolver is deterministic for the same user/session |
DomainValidationError | Resolver returned an IP, localhost, or invalid hostname | Return a valid FQDN from the resolver |
| Pre-MCD sessions rejected | Upgrading to resolver mode with existing sessions | Users need to re-authenticate. Sessions created in static mode lack domain metadata |
Inspecting session domain metadata:
const session = await auth0.getSession();
if (session) {
console.log("Session domain:", session.internal.mcd?.domain);
console.log("Session issuer:", session.internal.mcd?.issuer);
}