Claude Code with Auth0: Secure Auth Without the Gaps
Why Auth0 without CLAUDE.md ships auth that accepts the wrong tokens
Auth0 is a hosted identity platform that handles login, token issuance, social connections, multi-factor authentication, and role management so an application does not have to build any of it. The appeal is that you delegate the hardest part of any product, the part where a single mistake exposes every account, to a service that does it for a living. The risk is that the delegation is only as safe as the validation code on your side, and that validation code is exactly where an assistant generating "an Auth0 integration" tends to cut the corners that matter.
A Claude Code session that wires up Auth0 without project rules produces code that logs a user in and reads their profile, which is enough to look finished. What it skips is the part that makes the integration secure. It validates the JWT signature but not the aud claim, so a token minted for a different API of yours is accepted by this one. It checks the issuer with a string compare that a trailing slash defeats. It pulls roles from a custom claim without confirming the namespace, so an attacker who can influence one unprotected claim escalates to admin. It stores a Management API token with read:users update:users delete:users when the feature only needs to read one field. Each of these is the difference between an auth layer that rejects forged credentials and one that waves them through, and none of them shows up in a happy-path test.
This guide covers the CLAUDE.md configuration that locks Claude Code into Auth0 patterns that hold under attack: audience and issuer validation on every token check, a hard line between the frontend SDK and the backend validator, Management API tokens scoped to the single operation they perform, an Actions model that Claude implements correctly, and permission hooks that keep destructive Management API calls out of an automated session. For the self-hosted identity alternative, Claude Code with Keycloak covers the equivalent patterns on infrastructure you run. For the developer-first hosted option, Claude Code with Clerk covers the patterns for a React-native auth layer.
The Auth0 CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Auth0 integration it needs to declare: the tenant and domain, the SDKs in use, the token validation policy, the custom claim namespace, the Management API scope policy, the Actions conventions, and the hard rules that block the validation gaps Claude introduces most often.
# Auth0 rules
## Tenant
- Tenant domain: ${AUTH0_DOMAIN} (read from env, never hardcoded)
- Custom domain: auth.example.com (issuer must match this, not the tenant domain)
- API audience: https://api.example.com (the identifier, not a URL that resolves)
- Token signing algorithm: RS256 ONLY, never HS256 for APIs
## SDKs
- Frontend: @auth0/auth0-react for SPA, @auth0/nextjs-auth0 for Next.js
- Backend: express-oauth2-jwt-bearer or jose for manual validation
- Management: node-auth0 (auth0 npm package), server-side ONLY
## Token validation (MANDATORY)
- EVERY protected endpoint validates: signature, issuer, audience, expiry
- issuer MUST equal https://${AUTH0_DOMAIN}/ with the trailing slash
- audience MUST equal the API identifier exactly
- Validate via JWKS endpoint, cache keys, NEVER hardcode a public key
- NEVER validate a token client-side and trust the result on the server
## Custom claims (MANDATORY)
- Namespace ALL custom claims: https://example.com/roles, not "roles"
- Roles read ONLY from the namespaced claim set by an Action
- NEVER trust an un-namespaced claim, NEVER trust email_verified blindly
## Management API (MANDATORY)
- Token requested per-operation with the MINIMUM scopes for that call
- NEVER request read:users update:users delete:users when one suffices
- Management client instantiated server-side, token never sent to a browser
- Rate-limit-aware: respect the 429 Retry-After header
## Actions
- Post-login Actions set custom claims, enrich tokens, enforce MFA
- Secrets read from event.secrets, NEVER inlined in the Action code
- api.access.deny used for hard blocks, with a stable error code
## Hard rules
- NEVER skip audience validation on a token check
- NEVER compare issuer without the trailing slash
- NEVER accept HS256 for an API token
- NEVER read a role from an un-namespaced claim
- NEVER store a Management API token in client-accessible code
- NEVER request broad Management scopes for a narrow operation
- ALWAYS cache JWKS keys with a TTL, never fetch per request
- ALWAYS use the custom domain as the expected issuer when one is set
Five rules here prevent the majority of the security gaps Claude generates without them.
The audience validation rule is the one most often skipped. A JWT signed by your tenant is valid for every API in that tenant unless each API checks that the token's aud claim names it specifically. Without the check, a token a user legitimately holds for your low-privilege analytics API is accepted by your high-privilege admin API. Claude validates the signature, sees a valid token, and stops. The rule forces the aud comparison on every endpoint.
The issuer trailing-slash rule sounds pedantic and is not. Auth0 issues tokens with an iss of https://your-tenant.auth0.com/ including the trailing slash. A validator that compares against the same string without the slash rejects every legitimate token, so the developer "fixes" it by removing the issuer check entirely, which then accepts tokens from any issuer. The rule pins the exact expected string so the check is both correct and present.
The namespaced claim rule prevents the most common privilege escalation. Auth0 strips non-namespaced custom claims from tokens for OIDC compliance, but developers work around this by reading roles from a claim that an attacker can sometimes influence. The rule requires a collision-proof namespace like https://example.com/roles, set by a trusted post-login Action, and forbids reading authorization data from anywhere else.
Install and project structure
Install the SDKs the integration actually uses. A typical full-stack app has three: the frontend SPA SDK, the backend validator, and the Management client.
# Frontend (React SPA)
npm install @auth0/auth0-react
# Frontend (Next.js)
npm install @auth0/nextjs-auth0
# Backend validator (Express)
npm install express-oauth2-jwt-bearer
# Backend validator (framework-agnostic)
npm install jose
# Management API (server-side only)
npm install auth0
Keep the Management client and the validator in separate modules so Claude never imports the Management client into request-handling code where its token could leak.
src/
auth/
validate.ts , backend token validation, no secrets
management.ts , Management API client, server-side only
claims.ts , namespaced claim readers
middleware/
requireAuth.ts , the validation middleware
requireScope.ts , scope and role checks
The split keeps the responsibilities clear. The validator never needs a secret because it checks signatures against the public JWKS. The Management client holds privileged credentials and never touches a request path. Claude reads the structure from CLAUDE.md and respects the boundary.
Token validation done correctly
The single most important piece of an Auth0 integration is the backend validator. The Express SDK does the right thing if configured with both the audience and the issuer.
// src/middleware/requireAuth.ts
import { auth } from "express-oauth2-jwt-bearer";
export const requireAuth = auth({
audience: process.env.AUTH0_AUDIENCE, // https://api.example.com
issuerBaseURL: process.env.AUTH0_ISSUER, // https://auth.example.com/
tokenSigningAlg: "RS256",
});
Three settings make this correct. The audience forces the aud claim check, so a token for a different API is rejected. The issuerBaseURL is set to the custom domain with the trailing slash, so only tokens from the expected issuer pass. The tokenSigningAlg: "RS256" rejects any token signed with the symmetric HS256 algorithm, which closes the algorithm-confusion attack where a forged token claims to use a key the validator might mishandle.
When you validate by hand, for example in an edge function where the Express SDK does not run, the same three checks apply.
// src/auth/validate.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL(`${process.env.AUTH0_ISSUER}.well-known/jwks.json`)
);
export async function validateToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.AUTH0_ISSUER, // exact, with trailing slash
audience: process.env.AUTH0_AUDIENCE, // exact API identifier
algorithms: ["RS256"], // reject HS256
});
return payload;
}
The createRemoteJWKSet fetches and caches the signing keys from the JWKS endpoint, refreshing on key rotation. The jwtVerify call enforces issuer, audience, and algorithm in one pass and throws on any mismatch. There is no path here that accepts a token with the wrong audience or a token your tenant did not sign. That is the property the CLAUDE.md is protecting.
Reading roles without opening a hole
Authorization data, the roles and permissions that decide what a user can do, must come from a claim you control. The pattern is a post-login Action that writes roles into a namespaced claim, and a reader on the backend that only ever looks at that namespace.
// src/auth/claims.ts
const NAMESPACE = "https://example.com";
export function getRoles(payload: Record<string, unknown>): string[] {
const roles = payload[`${NAMESPACE}/roles`];
return Array.isArray(roles) ? (roles as string[]) : [];
}
export function hasRole(payload: Record<string, unknown>, role: string) {
return getRoles(payload).includes(role);
}
The reader looks at exactly one claim, the namespaced roles array, and treats a missing or malformed value as no roles rather than failing open. The namespace makes the claim impossible to confuse with a standard OIDC claim or an attacker-influenced field. Combined with a scope middleware, the result is authorization that depends only on data the tenant signed.
// src/middleware/requireScope.ts
import { Request, Response, NextFunction } from "express";
import { hasRole } from "../auth/claims";
export function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
const payload = (req as { auth?: { payload: Record<string, unknown> } }).auth?.payload;
if (!payload || !hasRole(payload, role)) {
return res.status(403).json({ error: "forbidden", code: "missing_role" });
}
next();
};
}
This middleware runs after requireAuth, so the token is already validated when it reads the claim. It returns a stable error code on failure, which keeps the client behaviour predictable.
Setting claims with Actions
Custom claims are set by a post-login Action, which runs in the Auth0 pipeline after authentication and before the token is issued. The CLAUDE.md rule is that secrets come from event.secrets, never the Action source, and that hard blocks use api.access.deny with a stable code.
// Auth0 post-login Action
exports.onExecutePostLogin = async (event, api) => {
const namespace = "https://example.com";
const roles = event.authorization?.roles || [];
api.idToken.setCustomClaim(`${namespace}/roles`, roles);
api.accessToken.setCustomClaim(`${namespace}/roles`, roles);
if (event.user.email_verified !== true) {
api.access.deny("email_not_verified");
}
};
The Action reads the roles Auth0 assigns through its RBAC system and writes them into the namespaced claim on both the ID token and the access token. The email_verified check denies access with a stable code that the application can map to a clear message. Because the roles originate inside the Action from Auth0's own authorization data, the backend can trust the namespaced claim completely. This is the trust chain the namespaced-claim rule depends on.
Management API with least privilege
The Management API is the powerful, dangerous part of Auth0. A token with broad scopes can read every user's profile, change emails, delete accounts, and rewrite tenant configuration. The CLAUDE.md rule is one token per operation with the minimum scopes, instantiated server-side, never sent to a browser.
// src/auth/management.ts
import { ManagementClient } from "auth0";
// Scoped to a single read operation. A different feature that updates
// users instantiates a different client with update:users only.
export function userReader() {
return new ManagementClient({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_M2M_CLIENT_ID!,
clientSecret: process.env.AUTH0_M2M_CLIENT_SECRET!,
scope: "read:users",
});
}
export async function getUserMetadata(userId: string) {
const client = userReader();
const user = await client.users.get({ id: userId });
return user.data.user_metadata ?? {};
}
The client is scoped to read:users and nothing more. A feature that needs to update a user instantiates a separate client with update:users, so a bug in the read path can never write. The credentials come from environment variables and the client is constructed in server-side code that a browser bundle never imports. This is the difference between a Management integration that can read one field and one that can delete the account table.
When you hit the rate limit, respect the Retry-After header rather than retrying blind.
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err: unknown) {
const e = err as { statusCode?: number; headers?: Record<string, string> };
if (e.statusCode === 429) {
const wait = Number(e.headers?.["retry-after"] ?? "1") * 1000;
await new Promise((r) => setTimeout(r, wait));
return fn();
}
throw err;
}
}
The handler reads the Retry-After value the Management API returns on a 429 and waits exactly that long before a single retry, which keeps the integration inside the rate budget instead of compounding the problem.
Frontend integration without leaking the boundary
The frontend SDK handles the login redirect, token storage, and silent renewal. The boundary the CLAUDE.md protects is that the frontend never makes an authorization decision the backend then trusts.
// React SPA
import { Auth0Provider, useAuth0 } from "@auth0/auth0-react";
export function App({ children }: { children: React.ReactNode }) {
return (
<Auth0Provider
domain={import.meta.env.VITE_AUTH0_DOMAIN}
clientId={import.meta.env.VITE_AUTH0_CLIENT_ID}
authorizationParams={{
redirect_uri: window.location.origin,
audience: import.meta.env.VITE_AUTH0_AUDIENCE,
}}
>
{children}
</Auth0Provider>
);
}
The audience in authorizationParams is what makes the SDK request an access token for your API rather than an opaque token good only for the userinfo endpoint. Without it, the token the frontend sends to your backend has no audience your validator recognises, and the integration fails in a way that tempts a developer to weaken the backend check. Setting it here keeps the backend strict.
When the frontend calls the API, it attaches the access token and lets the backend decide everything else.
function useApi() {
const { getAccessTokenSilently } = useAuth0();
return async (path: string) => {
const token = await getAccessTokenSilently();
const res = await fetch(`/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 403) throw new Error("forbidden");
return res.json();
};
}
The frontend renders different UI for different roles for usability, but it never gates a real capability. The backend validates the token and checks the role on every request, so a user who tampers with the frontend to reveal an admin button still gets a 403 from the server. This is the property that survives a hostile client.
Permission hooks for Auth0 operations
Auth0 has operations that change tenant state in ways that are hard to reverse, and the Management API can run them from a script. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.
{
"permissions": {
"allow": [
"Bash(npx auth0 apis list*)",
"Bash(npx auth0 apps list*)",
"Bash(npx auth0 roles list*)",
"Bash(npx auth0 logs tail*)"
],
"deny": [
"Bash(npx auth0 users delete*)",
"Bash(npx auth0 apps delete*)",
"Bash(npx auth0 apis delete*)",
"Bash(npx auth0 roles delete*)",
"Bash(*update:users*)",
"Bash(*delete:users*)"
]
}
}
The allow list permits the read-only inspection Claude needs to understand the tenant: listing APIs, applications, roles, and tailing logs. The deny list blocks the calls that delete users, applications, APIs, or roles, plus any shell invocation carrying a broad user-write scope. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can investigate the tenant without being able to change it destructively.
Common Claude Code mistakes with Auth0
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Missing audience validation
Claude generates: jwtVerify(token, JWKS, { issuer }) with no audience.
Correct pattern: include audience set to the exact API identifier so a token for a different API is rejected.
2. Issuer compared without the trailing slash
Claude generates: an issuer check against https://tenant.auth0.com with no slash, which fails, then gets removed.
Correct pattern: pin the exact issuer https://auth.example.com/ with the trailing slash and keep the check.
3. Roles read from an un-namespaced claim
Claude generates: payload.roles read directly from the token.
Correct pattern: read payload["https://example.com/roles"], set by a trusted post-login Action.
4. HS256 accepted for an API token
Claude generates: a validator with no algorithm restriction.
Correct pattern: algorithms: ["RS256"] to reject symmetric-key forgeries.
5. Over-scoped Management token
Claude generates: a Management client with read:users update:users delete:users for a read.
Correct pattern: one client per operation with the single minimum scope.
6. Frontend authorization trusted by the backend
Claude generates: a backend that trusts a role flag the frontend sent.
Correct pattern: the backend validates the token and reads the role from the claim on every request.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is auth code Claude generates correctly the first time.
Testing the Auth0 integration
The tests that matter for auth are the negative ones: confirming that a forged, expired, or wrong-audience token is rejected. A test suite that only checks the happy path proves nothing about security.
import { describe, it, expect } from "vitest";
import { validateToken } from "../src/auth/validate";
describe("token validation", () => {
it("rejects a token with the wrong audience", async () => {
const token = await mintTestToken({ aud: "https://other-api.example.com" });
await expect(validateToken(token)).rejects.toThrow();
});
it("rejects a token from the wrong issuer", async () => {
const token = await mintTestToken({ iss: "https://evil.example.com/" });
await expect(validateToken(token)).rejects.toThrow();
});
it("rejects an expired token", async () => {
const token = await mintTestToken({ exp: Math.floor(Date.now() / 1000) - 60 });
await expect(validateToken(token)).rejects.toThrow();
});
it("rejects an HS256 token", async () => {
const token = await mintTestToken({ alg: "HS256" });
await expect(validateToken(token)).rejects.toThrow();
});
});
Each test mints a token that is wrong in exactly one way and asserts that the validator throws. This is the suite that catches the regression where someone loosens a check to fix an unrelated bug. Run it in CI on every change to the auth module. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.
Building Auth0 that rejects forged tokens by default
The Auth0 CLAUDE.md in this guide produces an integration where every endpoint validates audience and issuer, roles come only from a namespaced claim set by a trusted Action, the algorithm is pinned to RS256, Management tokens carry the minimum scope for one operation, and the frontend never makes an authorization decision the backend trusts. The result is an auth layer that holds when someone probes it, not just when someone logs in normally.
The principle is the same as any security-sensitive integration with Claude Code. Auth0 without a CLAUDE.md produces code that authenticates the happy path and conceals the gaps that let a forged or misdirected token through. The CLAUDE.md is what makes the strict validation the default for Claude.
For the self-hosted identity alternative on infrastructure you control, Claude Code with Keycloak covers the equivalent patterns. For the developer-first hosted option with a React-native API, Claude Code with Clerk covers the SPA auth flow. For the framework-integrated session model, Claude Code with NextAuth covers auth inside a Next.js app.
Get Claudify. Ship an Auth0 integration that rejects the tokens it should and accepts only the ones it must.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify