Auth0 Integration Guide
█████╗ ██╗ ██╗████████╗██╗ ██╗ ██████╗
██╔══██╗██║ ██║╚══██╔══╝██║ ██║██╔═████╗
███████║██║ ██║ ██║ ███████║██║██╔██║
██╔══██║██║ ██║ ██║ ██╔══██║████╔╝██║
██║ ██║╚██████╔╝ ██║ ██║ ██║╚██████╔╝
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝Auth0 Integration Guide
Focus: When and how codeAmani uses Auth0 for client projects that specifically need it — enterprise SSO/SAML, B2B Organizations, or an inherited Auth0 tenant. Covers the
@auth0/nextjs-auth0v4 App Router SDK (Universal Login, middleware,auth0.getSession()), RBAC, the Management API via theauth0node SDK, and JWT verification for API routes.
Overview
Auth0 (an Okta company) is an identity platform built on OAuth 2.0 / OIDC: Universal Login hosts the sign-in page, the app receives an authorization code at a callback, exchanges it for an encrypted session cookie, and from then on reads the user from that session. It does social and enterprise connections, RBAC with permissions, post-login Actions, a Management API for programmatic user/role administration, and Organizations for B2B multi-tenancy.
Auth0 is listed as an auth option on motionstackstudios.com, but it is not a house default. The agency's own dashboard runs custom Argon2id password hashing + server-side sessions, and Clerk (see the clerk guide) is the managed-auth default for most builds — drop-in components, Svix-verified webhooks, simpler pricing. Auth0 only earns a place when a client's contract requires it:
Check first. Before adopting Auth0, query the
codeAmani-tech-stackMCP (search_guides/get_guide) and confirm Clerk or the dashboard's custom auth won't satisfy the requirement. Auth0 adds a vendor, per-MAU cost, and an extra tenant to operate — prefer the default unless the client specifically requires Auth0.
The OAuth/OIDC login flow the SDK wires up:
Official Documentation
| Resource | URL |
|---|---|
| Auth0 Docs (root) | https://auth0.com/docs |
Next.js SDK (@auth0/nextjs-auth0 v4) | https://github.com/auth0/nextjs-auth0 |
| Management API v2 | https://auth0.com/docs/api/management/v2 |
| Actions (post-login triggers) | https://auth0.com/docs/customize/actions |
| RBAC (roles & permissions) | https://auth0.com/docs/manage-users/access-control/rbac |
| Organizations (B2B) | https://auth0.com/docs/manage-users/organizations |
No first-party Auth0 MCP server is in the house stack. Drive the SDK from Claude Code with the patterns below; when unsure about a current API, query Context7 (
resolve-library-id "Auth0 Next.js SDK"→/auth0/nextjs-auth0) rather than recalling v3 patterns — v4 changed the entire surface.
Next.js App Router Setup (@auth0/nextjs-auth0 v4)
The v4 SDK is a clean break from v3. There is no handleAuth() catch-all route and no UserProvider import path you remember — instead you instantiate a single Auth0Client, mount it in middleware, and the SDK auto-serves /auth/login, /auth/logout, and /auth/callback.
npm install @auth0/nextjs-auth01. The Auth0 client
lib/auth0.ts:
import { Auth0Client } from "@auth0/nextjs-auth0/server";
// Reads AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET,
// and APP_BASE_URL from the environment automatically.
export const auth0 = new Auth0Client({
authorizationParameters: {
scope: "openid profile email offline_access",
// Set an audience to receive a JWT access token for your own API.
audience: process.env.AUTH0_AUDIENCE,
},
});2. Middleware mounts the routes
middleware.ts (project root):
import type { NextRequest } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function middleware(request: NextRequest) {
// Serves /auth/login, /auth/logout, /auth/callback and refreshes the session.
return await auth0.middleware(request);
}
export const config = {
matcher: [
// Run on everything except static assets and metadata files.
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};3. The login / logout UI
The SDK exposes the auth actions as plain links — no client component required.
app/layout.tsx:
import { auth0 } from "@/lib/auth0";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth0.getSession();
return (
<html lang="en">
<body>
<header style={{ display: "flex", justifyContent: "flex-end", gap: 12, padding: 16 }}>
{session ? (
<a href="/auth/logout">Log out ({session.user.name})</a>
) : (
<a href="/auth/login">Log in</a>
)}
</header>
{children}
</body>
</html>
);
}Gotcha: In v4 the login route is
/auth/login, not/api/auth/login(that was v3). To send the user somewhere specific after login, use/auth/login?returnTo=/dashboard. If you rename routes via theroutesoption onAuth0Client, update these links to match.
Protecting Routes
There are two layers: the middleware refreshes/attaches the session, and each protected page or API route reads it.
Server Components
app/dashboard/page.tsx:
import { redirect } from "next/navigation";
import { auth0 } from "@/lib/auth0";
export default async function DashboardPage() {
const session = await auth0.getSession();
if (!session) {
// Bounce through Universal Login, then return here.
redirect("/auth/login?returnTo=/dashboard");
}
return <h1>Welcome, {session.user.name}</h1>;
}Or wrap with the helper (note returnTo is required in the App Router — Server Components don't know their own URL):
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" }
);API Route Handlers
app/api/me/route.ts:
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 });
}
return NextResponse.json({ user: session.user });
}RBAC (Roles & Permissions)
Enable RBAC on the API in the Auth0 dashboard (APIs → your API → RBAC Settings → "Enable RBAC" + "Add Permissions in the Access Token"). Then assign roles to users; the role's permissions land in the access token's permissions claim.
Roles themselves don't appear in the ID token by default — surface them with a post-login Action under a namespaced custom claim (Auth0 silently drops non-namespaced claims):
// Auth0 Dashboard → Actions → Library → Post Login
exports.onExecutePostLogin = async (event, api) => {
const namespace = "https://motionstack.app";
const roles = event.authorization?.roles ?? [];
api.idToken.setCustomClaim(`${namespace}/roles`, roles);
api.accessToken.setCustomClaim(`${namespace}/roles`, roles);
};Read roles from the session, and permissions by decoding the access token:
// lib/rbac.ts
import { auth0 } from "@/lib/auth0";
const NS = "https://motionstack.app";
export async function getRoles(): Promise<string[]> {
const session = await auth0.getSession();
return (session?.user?.[`${NS}/roles`] as string[]) ?? [];
}
export async function requireRole(role: string): Promise<void> {
const roles = await getRoles();
if (!roles.includes(role)) {
throw new Response("Forbidden", { status: 403 });
}
}Gotcha: Custom claims must be fully-qualified URLs (a namespace you control). A bare
rolesclaim is stripped by Auth0 and will silently never appear in the token.
Management API (the auth0 node SDK)
For server-side user/role administration — listing users, assigning roles, updating metadata — use the auth0 node SDK's ManagementClient. Create a Machine-to-Machine application in the dashboard, authorize it for the Auth0 Management API, and grant the specific scopes (read:users, update:users, create:role_members, …). The SDK fetches and caches its own token via client credentials.
npm install auth0// lib/auth0-management.ts
import { ManagementClient } from "auth0";
export const management = new ManagementClient({
domain: process.env.AUTH0_DOMAIN!, // e.g. acme.us.auth0.com (no scheme)
clientId: process.env.AUTH0_M2M_CLIENT_ID!,
clientSecret: process.env.AUTH0_M2M_CLIENT_SECRET!,
});
/** Assign a role to a user (e.g. after a Stripe upgrade webhook). */
export async function grantRole(userId: string, roleId: string): Promise<void> {
await management.users.assignRoles({ id: userId }, { roles: [roleId] });
}
/** Persist app-level state on the Auth0 user record. */
export async function setPlan(userId: string, plan: string): Promise<void> {
await management.users.update({ id: userId }, { app_metadata: { plan } });
}
/** Look up a user by email (admin tooling). */
export async function findByEmail(email: string) {
const { data } = await management.usersByEmail.getByEmail({ email });
return data;
}Gotcha: The Management API is heavily rate-limited (and the M2M client may bill per token). Never call it on a hot request path — only from webhooks, admin actions, and background jobs. Use
app_metadata(server-controlled) for authorization-relevant fields anduser_metadata(user-editable) for preferences.
JWT Verification for APIs
When a separate service (a mobile app, a backend microservice, a third party) calls your API with a Bearer token issued by Auth0, verify the JWT against Auth0's published JWKS — check the signature, issuer, audience, and expiry. Use jsonwebtoken with jwks-rsa to fetch and cache the signing keys.
npm install jsonwebtoken jwks-rsa// lib/verify-jwt.ts
import jwt, { type JwtPayload } from "jsonwebtoken";
import { JwksClient } from "jwks-rsa";
const issuer = `https://${process.env.AUTH0_DOMAIN}/`;
const jwks = new JwksClient({
jwksUri: `${issuer}.well-known/jwks.json`,
cache: true,
rateLimit: true,
});
function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
jwks.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key!.getPublicKey());
});
}
/** Verify a bearer token (RS256) and return its claims. */
export function verifyAccessToken(token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getKey,
{
algorithms: ["RS256"],
issuer,
audience: process.env.AUTH0_AUDIENCE,
},
(err, decoded) => (err ? reject(err) : resolve(decoded as JwtPayload)),
);
});
}Use it to guard a machine-facing route:
// app/api/v1/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyAccessToken } from "@/lib/verify-jwt";
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Missing bearer token" }, { status: 401 });
}
try {
const claims = await verifyAccessToken(auth.slice(7));
const scopes = (claims.scope as string | undefined)?.split(" ") ?? [];
if (!scopes.includes("read:orders")) {
return NextResponse.json({ error: "Insufficient scope" }, { status: 403 });
}
return NextResponse.json({ sub: claims.sub, orders: [] });
} catch {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
}Note: This is for first-party browser sessions handled by the SDK plus separate API callers. For the browser app itself,
auth0.getSession()is the path — don't re-verify the SDK's own session cookie by hand.
Calling Your Own API From the App
When the app needs to call a downstream API with a real Auth0-issued access token, request it with auth0.getAccessToken() (the SDK handles refresh via offline_access):
// app/api/data/route.ts
import { NextResponse } from "next/server";
import { auth0 } from "@/lib/auth0";
export async function GET() {
const { token } = await auth0.getAccessToken();
const res = await fetch("https://data-api.example.com/records", {
headers: { Authorization: `Bearer ${token}` },
});
return NextResponse.json(await res.json());
}Organizations (B2B Multi-Tenancy)
For B2B clients, Organizations model each customer company as a tenant with its own members, roles, and (critically) its own enterprise connection — so Acme logs in via their Okta SAML and Globex via their Azure AD, all in one Auth0 tenant. Enable "Organizations" on the application, then route users through an org-scoped login:
// Send a user to log in within a specific organization.
// app/teams/[orgId]/login/route.ts
import { redirect } from "next/navigation";
export async function GET(_: Request, { params }: { params: { orgId: string } }) {
redirect(`/auth/login?organization=${params.orgId}&returnTo=/teams/${params.orgId}`);
}The active organization lands in the session as the org_id claim — gate org-scoped data on it. This is the primary reason a codeAmani client picks Auth0 over Clerk: per-organization SAML/OIDC enterprise connections out of the box.
Environment Variables
# Core SDK config (all required by @auth0/nextjs-auth0 v4)
AUTH0_DOMAIN=acme.us.auth0.com # tenant domain — NO https:// scheme
AUTH0_CLIENT_ID=... # the Regular Web App client
AUTH0_CLIENT_SECRET=... # server-side only — NEVER ship to the bundle
AUTH0_SECRET=... # 32-byte hex for cookie encryption: `openssl rand -hex 32`
APP_BASE_URL=http://localhost:3000 # your app's base URL (prod: https://app.example.com)
# Optional: audience for a JWT access token to your own API
AUTH0_AUDIENCE=https://api.example.com
# Management API (separate Machine-to-Machine application)
AUTH0_M2M_CLIENT_ID=...
AUTH0_M2M_CLIENT_SECRET=... # server-side onlyAdd these to
ENV_MASTER.mdand each project's.env.example.AUTH0_SECRETencrypts the session cookie — rotate it and every session is invalidated, so treat it like a signing key.AUTH0_DOMAINmust be the bare host (the v3AUTH0_ISSUER_BASE_URLwith a scheme is gone);AUTH0_BASE_URLwas renamed toAPP_BASE_URLin v4.
Automation Workflows
Claude Code slash command: scaffold Auth0 auth
.claude/commands/auth0-setup.md:
Scaffold @auth0/nextjs-auth0 v4 authentication for this Next.js App Router project.
First confirm Auth0 is actually required (enterprise SSO/SAML, B2B orgs, or an
existing tenant) — if not, recommend Clerk per the house default and stop. Then:
1. Install `@auth0/nextjs-auth0` if not already in package.json.
2. Create `lib/auth0.ts` exporting a configured `Auth0Client`.
3. Create `middleware.ts` calling `auth0.middleware(request)` with the asset matcher.
4. Add log-in / log-out links to `app/layout.tsx` (`/auth/login`, `/auth/logout`).
5. Add a protected `app/dashboard/page.tsx` using `auth0.getSession()`.
6. Add all five required env vars to `.env.local` and `.env.example`
(AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET, APP_BASE_URL).
7. Report manual dashboard steps: create the app, set Allowed Callback URLs to
`${APP_BASE_URL}/auth/callback` and Allowed Logout URLs to `${APP_BASE_URL}`.Usage: /project:auth0-setup
GitHub Actions: verify Auth0 config
# .github/workflows/auth0-verify.yml
name: Verify Auth0 Config
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci
- name: Check middleware exists
run: test -f middleware.ts || (echo "Missing middleware.ts!" && exit 1)
- name: Check required env vars documented
run: |
for v in AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_CLIENT_SECRET AUTH0_SECRET APP_BASE_URL; do
grep -q "$v" .env.example || echo "Warning: $v missing from .env.example"
doneCommon Use Cases
| Use Case | Approach |
|---|---|
| Default managed auth | Clerk (house default) — use Auth0 only when a client requires it |
| Default in-app auth | Custom Argon2 + sessions (the dashboard) — Auth0 doesn't replace it |
| Add Auth0 to Next.js | /project:auth0-setup slash command (v4 SDK) |
| Read the current user | auth0.getSession() in a Server Component / Route Handler |
| Protect a page | auth0.withPageAuthRequired(fn, { returnTo }) or a getSession() guard |
| Social / enterprise login | Configure connections in dashboard → Universal Login picks them up |
| RBAC / permissions | Enable RBAC on the API + namespaced-claim post-login Action |
| Admin user/role management | Management API via the auth0 node SDK (ManagementClient) |
| Verify a machine-issued JWT | jsonwebtoken + jwks-rsa against the tenant JWKS |
| Call your own API | auth0.getAccessToken() with an audience configured |
| B2B SSO / per-tenant SAML | Auth0 Organizations + enterprise connections |
| MFA enforcement | Auth0 dashboard → Security → Multi-factor Auth (or a post-login Action) |
Troubleshooting
| Issue | Fix |
|---|---|
404 on /api/auth/login | v4 uses /auth/login (no /api); update links — this is the #1 v3→v4 break |
callback URL mismatch | Add ${APP_BASE_URL}/auth/callback to the app's Allowed Callback URLs in the dashboard |
| Session not set after login | Ensure auth0.middleware(request) runs and the matcher isn't excluding /auth/* |
AUTH0_DOMAIN errors / invalid issuer | Use the bare host (acme.us.auth0.com), no https:// scheme — that was v3's ISSUER_BASE_URL |
| All users logged out unexpectedly | AUTH0_SECRET changed (or differs across instances) — it must be stable and identical everywhere |
| Roles missing from token | Add a post-login Action setting a namespaced custom claim; bare roles is stripped |
permissions claim empty | Enable RBAC + "Add Permissions in the Access Token" on the API, and set an audience |
jwt malformed / invalid signature | Confirm RS256, the JWKS URI matches https://${AUTH0_DOMAIN}/.well-known/jwks.json, and audience is correct |
Management API 429 | You're calling it on a hot path — move to webhooks/jobs; the API is rate-limited |
Management API 403 insufficient_scope | Grant the specific scope (e.g. update:users) to the M2M app on the Management API |