Next.js + Clerk security — auth as a service
Clerk is one of the safer auth choices for Next.js. The provider handles MFA, OAuth, password policies, session management, and CSRF correctly. Your integration is what gets audited, and the three places it goes wrong are consistent across teams. First, the `clerkMiddleware` matcher misses a route. Anything outside the matcher runs unauthenticated. The default matcher pattern catches /app/*, but a file like `/pages/api/admin/do-thing.ts` added later can slip outside the matcher without anyone noticing. Second, a Server Action or API route calls `auth()` but doesn't assert the returned value — `auth()` returns `{ userId: null }` for unauthenticated callers, and TypeScript doesn't force you to check. Third, webhook endpoints don't verify the `svix` signature Clerk includes, so any attacker can POST fake user-created / user-deleted events to your system. Organization-level authorization is a subtle fourth gotcha: a user can be in multiple Clerk orgs and the current org is separate from authentication. Mix these up and you have a BOLA vulnerability across your tenant model.
What breaks on this stack
clerkMiddleware matcher gaps
Routes that don't match the middleware matcher run unauthenticated. A drifting matcher is the most common Clerk bug. Review the matcher whenever routes change and prefer a positive list over negative exclusions.
Server Actions that don't assert auth()
`const { userId } = auth()` returns `{ userId: null }` for unauthenticated. Always `if (!userId) throw new Error('unauth')`. TypeScript's `userId: string | null` does not compile-error this for you.
Webhook signature not verified
Clerk webhooks include a `svix-id` and `svix-signature` header. Without verifying, an attacker can forge user.created / user.deleted events. Use Clerk's `svix` package to verify every incoming webhook.
Organization context ignored
Users can be in multiple orgs. `auth().orgId` is the active org; `auth().orgRole` is their role in it. Queries must filter on orgId, not just userId, or tenants leak across.
Public metadata written from client
`user.publicMetadata` is writable from the backend but must never be updated from the browser. Don't expose your Clerk Secret Key to the client.
Pre-ship checklist
- clerkMiddleware matcher reviewed on every routing change
- Every Server Action starts with `const { userId } = auth(); if (!userId) throw ...`
- Every Clerk webhook verifies svix signature
- Organization-scoped queries include orgId in WHERE clause
- Clerk Secret Key never ships to client
- publicMetadata and privateMetadata separated intentionally
- Session token refresh handled at middleware layer
- MFA required for admin-role users
- Webhook endpoint rate-limited to avoid event replay
- Clerk Production vs Development instance configured per environment
Starter config
// middleware.ts — Clerk matcher + CVE-2025-29927 guard
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublic = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks(.*)", // Clerk webhooks; must verify signature inside
]);
export default clerkMiddleware((auth, req) => {
if (req.headers.has("x-middleware-subrequest")) {
return new Response("rejected", { status: 400 });
}
if (!isPublic(req)) auth().protect();
});
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
// app/api/webhooks/clerk/route.ts — verify svix signature
import { Webhook } from "svix";
export async function POST(req: Request) {
const body = await req.text();
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
const evt = wh.verify(body, {
"svix-id": req.headers.get("svix-id")!,
"svix-signature": req.headers.get("svix-signature")!,
"svix-timestamp": req.headers.get("svix-timestamp")!,
});
// handle evt
return new Response("ok");
}
// Server Action pattern
"use server";
import { auth } from "@clerk/nextjs/server";
export async function createOrder(input: FormData) {
const { userId, orgId } = auth();
if (!userId) throw new Error("unauthenticated");
if (!orgId) throw new Error("no-org");
// ... mutation scoped by userId + orgId ...
}