backend-auth-clerk
Skill1 starsChanged 10 months agoMITView on GitHub
Clerk authentication for a Koa backend — verifying session JWTs, protecting routes by default, org-scoping queries (Clerk Organizations as the tenancy primitive), and admin gating. Use when asked to "add auth", "protect a route", "add auth to an endpoint", "scope by org", "require admin", "check the current user", or anything touching Clerk, sessions, tokens, or tenancy on the backend.
- Reads credentials
In this repository14 other files it gives its agents.
- AGENTS.mdAGENTS.md
- skill-creatorSkill
- backend-ai-agentSkill
- backend-ai-toolsSkill
- backend-compressionSkill
- backend-metricsSkill
- backend-model-creationSkill
- backend-route-creationSkill
- backend-scaffoldingSkill
- backend-websocketSkill
- frontend-auth-clerkSkill
- frontend-hooks-creationSkill
- frontend-scaffoldingSkill
- new-project-scaffoldingSkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 12
Clerk is the source of truth for identity: No local User model, no session store, no webhooks, no user sync.
- Very specific: line 13
Clerk Organizations is the tenancy primitive: Every tenant-owned document carries an indexed orgId holding the Clerk org id (org_...) from …
- Distinctive: line 20
If the repo doesn't have Clerk auth yet, create these pieces.
- Very specific: line 179
Minified org claims: Don't write new code against only the legacy org_* claim names from old docs — it fails silently on Clerk v5+ tokens.
- Very specific: line 182
Fail at boot on missing config: Keep the startup() secret-key check — a silently empty key surfaces as confusing 401s on the first request.
- Very specific: line 192
Add all three to .env.example and turbo's globalEnv.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Clerk Auth (Backend)
Clerk authentication for the standard Koa + koa-zod-router + Mongoose backend. Read this before touching anything auth- or tenancy-related. The webapp half — token attachment, route guards, org-switch resets — is in the frontend-auth-clerk skill.
Architecture (the three rules)
- Clerk is the source of truth for identity. No local User model, no session store, no webhooks, no user sync. If server-side identity beyond ids is ever needed, fetch it live from Clerk and merge into the response; never mirror names/avatars into the database.
- Clerk Organizations is the tenancy primitive. Every tenant-owned document carries an indexed
orgIdholding the Clerk org id (org_...) from the session JWT. Every query on those models MUST includeorgId. - Protected by default.
requireAuthis global middleware inmain.ts— every/api/*route except the health check requires a valid session token. New routers are automatically protected; a route is never opted into auth, it would have to be deliberately mounted aboverequireAuthto opt out (don't).
Package: @clerk/backend (hand-rolled Koa middleware — no Express/Next shims needed).
Implementation
If the repo doesn't have Clerk auth yet, create these pieces. If it does, they already look like this — use them, don't reinvent.
Config (src/config/index.ts)
Env is read only in config, per the config convention:
clerk: {
secretKey: process.env.CLERK_SECRET_KEY || '',
publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY || '',
// azp allowlist for session JWTs; default to the local webapp dev origin.
authorizedParties: process.env.CLERK_AUTHORIZED_PARTIES?.split(',') ?? ['http://localhost:4200'],
},Middleware (src/middleware/auth.ts)
import type { Context, Next } from 'koa';
import { createClerkClient, verifyToken } from '@clerk/backend';
import config from '../config';
import { createLogger } from '../lib/logger';
const log = createLogger('auth');
export const clerk = createClerkClient({
secretKey: config.clerk.secretKey,
publishableKey: config.clerk.publishableKey,
});
export interface AuthState {
userId: string;
sessionId: string;
orgId?: string;
orgRole?: string;
orgSlug?: string;
}
async function verifyBearerToken(token: string): Promise<AuthState> {
const payload = await verifyToken(token, {
secretKey: config.clerk.secretKey,
authorizedParties: config.clerk.authorizedParties,
});
// Clerk v5+ session tokens minify org claims to o.id / o.rol / o.slg and
// drop the 'org:' role prefix; older tokens use org_id / org_role / org_slug.
const raw = payload as Record<string, unknown>;
const orgClaim = raw.o as { id?: string; rol?: string; slg?: string } | undefined;
const orgId = (raw.org_id as string | undefined) ?? orgClaim?.id;
const rawRole = (raw.org_role as string | undefined) ?? orgClaim?.rol;
const orgRole = rawRole && !rawRole.startsWith('org:') ? `org:${rawRole}` : rawRole;
const orgSlug = (raw.org_slug as string | undefined) ?? orgClaim?.slg;
return { userId: payload.sub, sessionId: payload.sid, orgId, orgRole, orgSlug };
}
export async function requireAuth(ctx: Context, next: Next) {
const header = ctx.headers.authorization;
if (!header?.startsWith('Bearer ')) {
ctx.status = 401;
ctx.body = { message: 'Authentication required' };
return;
}
let auth: AuthState;
try {
auth = await verifyBearerToken(header.slice('Bearer '.length));
} catch (err) {
log.warn({ err, url: ctx.request.url }, 'Token verification failed');
ctx.status = 401;
ctx.body = { message: 'Invalid or expired token' };
return;
}
ctx.state.auth = auth;
// Outside the try/catch: a downstream error must not be rebranded as a 401.
await next();
}
export async function requireAdmin(ctx: Context, next: Next) {
const auth = ctx.state.auth as AuthState | undefined;
if (!auth) {
ctx.status = 401;
ctx.body = { message: 'Authentication required' };
return;
}
let isAdmin = false;
try {
const user = await clerk.users.getUser(auth.userId);
isAdmin = user.publicMetadata?.role === 'admin';
} catch (err) {
log.warn({ err, userId: auth.userId }, 'Failed to load Clerk user for admin check');
}
if (!isAdmin) {
ctx.status = 403;
ctx.body = { message: 'Admin access required' };
return;
}
await next();
}
export function getOrgId(ctx: Context): string {
const auth = ctx.state.auth as AuthState | undefined;
if (!auth?.orgId) {
throw Object.assign(new Error('No active organization on this session'), { status: 403 });
}
return auth.orgId;
}Wiring (src/main.ts)
Register globally after the health check, before the router mounts. Admin routers get requireAdmin composed at the mount:
app.use(requireAuth);
app.use(mount('/api/things', thingRoutes.routes()));
// The router middleware's context generic doesn't line up with plain Koa's;
// at runtime it's ordinary middleware.
const adminHandler = adminRoutes.routes() as unknown as Koa.Middleware;
app.use(mount('/api/admin', (ctx, next) => requireAdmin(ctx, () => adminHandler(ctx, next))));And fail fast in startup():
if (!config.clerk.secretKey) {
log.error('CLERK_SECRET_KEY is not set — refusing to start');
process.exit(1);
}Using auth in a route
import { getOrgId } from '../middleware/auth';
import type { AuthState } from '../middleware/auth';
handler: async (ctx) => {
const orgId = getOrgId(ctx); // throws 403 if session has no active org
const { userId } = ctx.state.auth as AuthState; // Clerk user id (JWT sub), when needed
const results = await Thing.find({ orgId }); // ALWAYS filter by orgId
// creates: new Thing({ ...body, orgId }) — ALWAYS stamp orgId
// lookups: Thing.findOne({ id, orgId }) — 404s double as cross-tenant denial
},getOrgId(ctx)first, at the top of every org-scoped handler. The thrownstatus: 403error is turned into a response by the global error handler.- New models holding tenant data need
orgId: { type: String, required: true, index: true }and compound indexes leading withorgId(seebackend-model-creation). - The admin role is app-level, NOT the org role. Set it in the Clerk dashboard: Users → user → Metadata → Public →
{ "role": "admin" }.
Gotchas (learned the hard way)
await next()goes OUTSIDE the try/catch in auth middleware. Otherwise a downstream validation 400 gets caught and rebranded as a 401/500.- Minified org claims. Don't write new code against only the legacy
org_*claim names from old docs — it fails silently on Clerk v5+ tokens.verifyBearerTokenabove handles both forms. - koa-mount doesn't compose middleware.
mount('/path', router.routes())takes ONE middleware — per-router middleware needs the wrapper-lambda pattern above. Per-route middleware can use koa-zod-router'spre: [mw]option instead. requireAdmincosts a Clerk API round-trip per request (clerk.users.getUser). Fine for a rarely-hit admin surface; if an admin check lands on a hot path, move the role into a session-token custom claim instead.- Fail at boot on missing config. Keep the
startup()secret-key check — a silently empty key surfaces as confusing 401s on the first request.
Environment
| Var | Where used |
CLERK_SECRET_KEY | backend only; boot fails without it |
VITE_CLERK_PUBLISHABLE_KEY | webapp (import.meta.env — MUST be VITE_-prefixed) and backend createClerkClient |
CLERK_AUTHORIZED_PARTIES | optional comma-separated azp allowlist; defaults to the local webapp origin |
Add all three to .env.example and turbo's globalEnv; direnv loads them from .env.local.
Verifying auth manually
curl -s localhost:3333/api # 200 — health stays public
curl -s localhost:3333/api/things # 401 — no token
curl -s localhost:3333/api/admin/... -H "Authorization: Bearer $TOKEN" # 403 unless role=adminFor automated integration tests, use the networkless pattern: mint Clerk-shaped RS256 JWTs with a local test keypair and set CLERK_JWT_KEY (a PEM public key verifyToken accepts) so the real verification path runs with no network, no mocks, and no Clerk test-org quota. Test tokens must mirror real claims, including the minified o: {id, rol, slg} form.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

