11ai clerk sessions
Version baseline: Clerk Core 3 and Clerk API 2026-05-12; each Clerk SDK has its own compatible semver (for example @clerk/nextjs v7.5.2 or newer). Inspect the installed SDK and the API-version compatibility table before editing.
The distinction that matters: Core 3's Show decides what renders, and auth() decides what a request is allowed to do. Hiding a button changes nothing about whether the route serves data. Every route returning private data must check on the server.
Inspect first
ls -la middleware.ts src/middleware.ts 2>/dev/null
grep -rn 'clerkMiddleware\|createRouteMatcher\|auth.protect' middleware.ts src/middleware.ts 2>/dev/null
grep -rn 'Show\|SignedIn\|SignedOut\|Protect' --include='*.tsx' app/ components/ 2>/dev/null | wc -l
grep -rn 'await auth()\|auth.protect()\|has({' --include='*.ts' --include='*.tsx' app/ 2>/dev/null | wc -l
Compare the last two counts. SignedIn, SignedOut, and Protect are included only to detect removed Core 2 patterns. A large number of component guards with almost no server checks means authorization lives in the browser, and every route is reachable directly with curl.
Read the middleware matcher too. A matcher that excludes a section leaves it unprotected regardless of what the code inside does, and it is also why auth() sometimes throws.
Protect at the middleware, default-deny
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
const isPublicRoute = createRouteMatcher([
"/",
"/pricing",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks(.*)",
])
export default clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect()
}
})
export const config = {
matcher: ["/((?!_next|[^?]*\\.[^?]*).*)", "/(api|trpc)(.*)"],
}
Protect everything except an explicit public list. A page added next month is then protected without anyone remembering, whereas listing protected routes leaves every new route open.
Keep sign-in, sign-up, and webhook routes public. Protecting sign-in redirects it to itself; a webhook sender has no session and authenticates by signature instead.
Middleware does a second job: it makes auth() available in server code. The error mentioning clerkMiddleware not being detected means the matcher does not cover that route, not that the calling code is wrong.
Read the session on the server
import { auth, currentUser } from "@clerk/nextjs/server"
const { userId, orgId, orgRole, sessionClaims, has } = await auth()
if (!userId) redirect("/sign-in")
export async function GET() {
const { userId } = await auth()
if (!userId) return Response.json({ error: "unauthorized" }, { status: 401 })
return Response.json(await listProjectsForUser(userId))
}
Prefer auth() over currentUser() when the id is enough — currentUser() makes an API call on every request. Use auth.protect() when you want it to throw and redirect rather than branching yourself.
Two rules that prevent real vulnerabilities:
- Derive the user and organization from the session, never from a request body, query string, or header. A caller-supplied
userIdis an authorization bypass with a validator in front of it. - Scope the query, do not filter afterwards. Pass
userIdororgIdinto the database query rather than fetching broadly and discarding rows in code.
Check permissions, not just presence
const { has } = await auth()
if (!has({ permission: "org:invoices:read" })) {
return Response.json({ error: "forbidden" }, { status: 403 })
}
if (!has({ role: "org:admin" })) return forbidden()
Being signed in is not being allowed. Middleware proves a session exists; the route still has to decide whether this user may perform this action, on this record.
Prefer permission checks over role checks. A permission survives a role rename and expresses the intent directly.
await auth.protect({ permission: "org:invoices:read" })
For per-record access, load the record and compare its owner to the session's userId or orgId. A permission grants a capability, not access to somebody else's row.
Session claims and their limit
sessionClaims carries what the dashboard's session token customization includes, which lets middleware read a flag without an API call. The token has a size limit — roughly a few kilobytes — so keep claims to small scalars such as a plan name or an onboarding flag. Putting a list of permissions or a profile object in there makes the token oversized and authentication starts failing intermittently.
Read authorization data from publicMetadata or privateMetadata, never unsafeMetadata. The user can write unsafeMetadata from the browser, so a role read from it is self-granted.
A claim added in the dashboard appears only in tokens minted afterwards. Existing sessions keep the old shape until they refresh, so code reading a new claim must tolerate its absence.
Verify a token at an API boundary
For a separate service or a mobile client:
import { verifyToken } from "@clerk/backend"
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
authorizedParties: ["https://app.example.com"],
})
Verify the signature, the expiry, and authorizedParties. Decoding the token to read sub without verifying accepts anything a caller invents. Setting authorizedParties is what stops a token minted for another origin being replayed against your API.
Revoke and end sessions
const client = await clerkClient()
await client.sessions.revokeSession(sessionId)
await client.users.getUserList({ userId: [userId] })
Revocation matters for deprovisioning: a user removed from your database who still holds a valid session still has access. Revoke their sessions as part of removing access, and treat a role downgrade the same way if the role lives in a session claim — the old claim persists until the session refreshes.
Verify
curl -ia protected route with no cookie — expect 401 or a redirect, never data.- Call it as a user without the required permission — expect 403.
- Call it with another user's record id — expect a refusal, not their data.
- Confirm
auth()works in a server component without throwing, which proves the matcher covers it. - Sign out and confirm protected routes stop serving.
- Revoke a session and confirm the next request is rejected.
- Confirm the session token is not oversized by checking that authentication holds after adding a claim.
Steps 1 to 3 are the ones that distinguish real protection from hidden interface.
Report
State the middleware matcher and whether protection is default-deny, which routes are public and why, where each protected route checks the session and on which permission or role, how records are scoped to the caller, which session claims are configured and their approximate size, how tokens are verified at any API boundary, and the verification results including the unauthenticated, forbidden, and wrong-record checks. Flag any authorization done only in components, any identity taken from the request, and any role read from unsafeMetadata.