Clerk Next.js security: the auth mistakes AI tools keep making
Clerk Next.js security comes down to one rule: verify the user on the server, inside every route handler and server action that touches data. AI tools often check only in middleware, read a userId from the request body, or keep roles where users can edit them. Auth.js (NextAuth) has the same traps, plus weak AUTH_SECRET values and open redirects.
QUICK CHECKLIST
- Call auth() inside every route handler and server action
- Take userId from the session, never from the request body
- Keep roles in publicMetadata or your DB, never unsafeMetadata
- Generate AUTH_SECRET with 32+ random characters
- Allow only local paths in callbackUrl redirects
- Verify webhook signatures before any database write
Clerk Next.js security: where the checks have to live
Clerk and Auth.js both answer one question: who is making this request? Neither decides what that person may do. Your code has to ask the second question everywhere it reads or writes data.
Middleware is code that runs before a request reaches your pages and API routes. AI tools usually put the check there. It works for redirecting signed-out visitors away from a dashboard, and falls short as the only gate. Clerk's docs say auth() requires clerkMiddleware() to be configured, and in current versions clerkMiddleware() doesn't block a route until you tell it to.
The Auth.js docs say it directly: "You should not rely on the proxy exclusively for authorization. Always ensure that the session is verified as close to your data fetching as possible."
Anyone can skip the page and call your API with curl, a command-line tool for sending web requests. If the handler has no check of its own, it answers. Server actions work the same way, as the Next.js server actions security guide explains. For the middleware side, read Next.js middleware authentication.
How attackers use these auth mistakes
None of these need a clever exploit. The attacker signs up for a free account, opens dev tools and edits a request. These are the eight patterns:
- Middleware only. The page redirects to sign-in, but
fetch("/api/projects")goes straight to the handler. The matcher in the Auth.js docs skips everyapipath, so it's only safe if each handler checks the session itself. - No
auth()in route handlers or server actions. The code runs for anyone who can send the request. - A
userIdtaken from the request body. The attacker sends someone else's id. That's an IDOR, short for insecure direct object reference: changing an id to reach someone else's record. - Roles in
unsafeMetadata. Clerk's docs say unsafe metadata can be modified directly from the frontend, so users can give themselvesrole: "admin". - Role mistakes in Auth.js. Either sign-up accepts
rolefrom the request body, or the app authorizes from a role cached in the JWT, which stays stale after you demote someone. - A weak, placeholder or leaked
AUTH_SECRET. Auth.js uses it to encrypt cookies and tokens. Anyone who finds it can forge a session your app accepts. - An open
callbackUrl. If a customredirectcallback or your own login code passes it through unchecked, an attacker can link to your real login page with the target set to a lookalike site. After sign-in, your app forwards the victim there. This flaw is called an open redirect. - Unsigned webhooks. A webhook is a request Clerk sends to your server when something happens, such as a signup. The route has to be public, so without a signature check anyone can post a fake
user.createdevent. See webhook signature verification.
Clerk fixes: auth() in route handlers and publicMetadata vs unsafeMetadata
Call auth() at the top of every route handler and server action. Clerk's docs say it works in Server Components, Route Handlers and Server Actions, and only on the server. Take the user id from it and from nowhere else.
// app/api/projects/route.ts
export async function POST(req: Request) {
const { userId, name } = await req.json() // the browser picks userId
const project = await db.project.create({ data: { userId, name } })
return Response.json(project)
}// app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server"
export async function POST(req: Request) {
const { userId } = await auth()
if (!userId) return new Response("Unauthorized", { status: 401 })
const { name } = await req.json()
const project = await db.project.create({ data: { userId, name } })
return Response.json(project)
}auth.protect() is a shorter form. Per Clerk's docs it redirects unauthenticated page requests to sign-in and returns 401 for unauthenticated Server Action requests and 404 for other unauthenticated requests. If you want a plain 401 from an API route, check userId yourself as above.
Roles live in metadata, and the three types have different access rules. This is the Clerk publicMetadata vs unsafeMetadata split from Clerk's docs:
| Frontend API | Backend API | |
|---|---|---|
| Public metadata | Read | Read and write |
| Private metadata | No access | Read and write |
| Unsafe metadata | Read and write | Read and write |
Source: Clerk docs, User metadata. Only the first two are safe for roles.
Unsafe metadata suits onboarding answers, since it's the only type you can set during sign-up. It's tempting for roles because it works straight from a client component. Put the role in public or private metadata and set it from your server.
// Client component: any signed-in user can run this
await user.update({ unsafeMetadata: { role: "admin" } })
// app/api/admin/users/route.ts
import { currentUser } from "@clerk/nextjs/server"
export async function GET() {
const user = await currentUser()
if (user?.unsafeMetadata.role !== "admin") {
return new Response("Forbidden", { status: 403 })
}
return Response.json(await db.user.findMany())
}// app/api/admin/users/route.ts
import { auth, clerkClient } from "@clerk/nextjs/server"
export async function GET() {
const { userId } = await auth()
if (!userId) return new Response("Unauthorized", { status: 401 })
const client = await clerkClient()
const user = await client.users.getUser(userId)
if (user.publicMetadata.role !== "admin") {
return new Response("Forbidden", { status: 403 })
}
return Response.json(await db.user.findMany())
}
// Set the role from your own server code only:
// await client.users.updateUserMetadata(userId, { publicMetadata: { role: "admin" } })Set the value with Clerk's Backend API. Clerk's docs warn that server-side changes don't reach the session token until the next refresh, and that getUser() is subject to rate limits.
Auth.js fixes: protecting routes, roles, AUTH_SECRET and callbackUrl
For Auth.js protecting routes, use the auth export from your auth.ts. In a page or server action, await auth() returns the session or nothing. In a route handler, wrap it and check req.auth:
// app/api/admin/route.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"
export const GET = auth(function GET(req) {
if (req.auth) return NextResponse.json(req.auth)
return NextResponse.json({ message: "Not authenticated" }, { status: 401 })
})A proxy.ts or middleware.ts that re-exports auth is a fine extra layer for redirects. Don't make it the only one.
A role in the session is fine for hiding a button, because the browser can read it. For anything that changes data, read the role from your database on the server. A JWT keeps its old role until it expires. Never let the sign-up body set role, and have the session callback copy only the user id (token.sub).
AUTH_SECRET is the only strictly required Auth.js variable. The docs say it encrypts cookies and tokens and should be a cryptographically secure random string of at least 32 characters. Generate it with npm exec auth secret or openssl rand -base64 33. Keep it out of git and out of NEXT_PUBLIC_ variables, which Next.js copies into the browser bundle. The .env file leak guide covers what happens when a .env ships.
By default Auth.js only lets relative and same-origin URLs through. A callbackUrl open redirect appears when you override the redirect callback to return url unchanged, or when your own code runs redirect(searchParams.get("callbackUrl")). Send every target through one helper:
export function safeRedirect(target: string | null, fallback = "/dashboard") {
if (!target || !target.startsWith("/")) return fallback
if (target.startsWith("//") || target.startsWith("/\\")) return fallback
return target
}The same rule applies to Clerk redirect parameters if you build them by hand.
How to fix Clerk Next.js security and Auth.js mistakes, step by step
Work through your repo in this order. Each step closes one of the eight holes.
List every entry point
Search for
route.tsfiles, functions marked'use server'and webhook routes. Each one is a public door.Add the session check first
Call
auth()at the top. Return 401 when there's no user, before you parse the body or touch the database.Take identity from the session
Replace any
userIdorownerIdread from the body, query string or form data with the id fromauth(). Put it in everywhereclause.Move roles server-side
Use Clerk public or private metadata, or a database column. Set it only from server code and stop reading
unsafeMetadatafor permissions.Lock down secrets and redirects
Generate
AUTH_SECRETwithnpm exec auth secret. Send everycallbackUrlthrough one helper that allows local paths only.Sign your webhooks
Verify the provider's signature on the raw body before any write. Check the current Clerk docs for the verify helper.
Test it yourself
Run these checks against your own app only.
Check 1: call a protected API while signed out.
curl -i https://YOUR-APP.example.com/api/projects
Bad result: 200 with real data. Good result: 401, or 404 if you use auth.protect().
Check 2: signed in as test user A, send test user B's id from the browser console.
await fetch("/api/projects", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userId: "user_TEST_B", name: "probe" }),
}).then((r) => r.json())Bad result: the new project belongs to B. Good result: it belongs to A, or you get a 400 or 403.
Check 3: in a signed-in Clerk tab, write a role to yourself.
await window.Clerk.user.update({ unsafeMetadata: { role: "admin" } })The call succeeding is normal, because that's how unsafe metadata works. Now load your admin page or admin API. Bad result: you get in. Good result: 403. Also run grep -rn "unsafeMetadata" app lib and review every hit.
Check 4: open /login?callbackUrl=https://example.com (or /api/auth/signin?callbackUrl=... for Auth.js, /sign-in?redirect_url=... for Clerk), then sign in with a test account. Also try callbackUrl=//example.com. Bad result: you land on example.com. Good result: you land on your own dashboard.
For AUTH_SECRET, check the deployed value in your host's env settings. It should be 32+ random characters from npm exec auth secret or openssl rand, not typed by hand. This command should list only .env.example:
git ls-files | grep -E "^\.env"
Fix prompt you can paste
Give this to Cursor or Claude Code. It asks for file and line numbers so you can review each change. If you want a second look afterwards, you can run Leakcade on your app.
Audit auth in this Next.js App Router repo (Clerk and/or Auth.js). 1. List every route.ts under app/, every function or file marked 'use server', and every webhook route. For each, say whether it calls auth() before touching data. Add the check where it is missing and return 401 when there is no user. Do not count middleware.ts or proxy.ts as a check, and flag any matcher that skips /api. 2. Find userId, ownerId, orgId or role read from req.json(), formData, searchParams or headers. Replace them with the id from auth() and add that id to the where clause of every database query. 3. Clerk: find unsafeMetadata used for role, plan, isAdmin or permissions. Move those to publicMetadata set server-side through the Clerk Backend API, or to a database column. 4. Auth.js: make sure sign-up routes do not accept role from the request body. Make the session callback copy only the user id. For admin actions, re-read the role from the database. 5. Check AUTH_SECRET: not hard coded, not in a NEXT_PUBLIC_ variable, not committed, not a placeholder. 6. Find redirects built from callbackUrl, redirect_url, next or returnTo, and any custom Auth.js redirect callback. Allow only same-origin targets through one shared helper. 7. Check that webhook routes verify the provider's signature on the raw body before any write. First output a findings list, one row per finding, as `path:line | issue | proposed fix`. Wait for my approval. Then make the approved changes and explain each one in one or two sentences. Do not change unrelated code.
Further reading
- Clerk docs: auth() helper for the Next.js App Router
- Clerk docs: User metadata (public, private, unsafe)
- Auth.js docs: Protecting resources
- Auth.js docs: Deployment and environment variables
FAQ
Does clerkMiddleware protect my API routes?
Not by default. In current Clerk versions clerkMiddleware() lets every route through until you tell it to protect it (for example with auth.protect()). Call auth() inside each route handler and server action so the check sits next to the data.
What is the difference between Clerk publicMetadata and unsafeMetadata?
Public metadata can be read by the frontend but only set on the backend. Unsafe metadata can be read and set from the frontend, so users can edit it. Use public or private metadata for roles.
How do I protect an API route with Auth.js in Next.js?
Wrap the handler with the auth export from your auth.ts and check req.auth. Return a 401 response when it's empty. Do this in each handler even if you have a proxy.ts or middleware.ts.
How long should AUTH_SECRET be?
The Auth.js docs recommend a cryptographically secure random string of at least 32 characters. Generate one with npm exec auth secret or openssl rand -base64 33. Don't commit it or expose it in a NEXT_PUBLIC_ variable.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms