agentleFS
Workshop-VenturesWorkshop-Ventures/skills/backend-auth-clerk/SKILL.md

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

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
  1. Distinctive:

    Clerk is the source of truth for identity: No local User model, no session store, no webhooks, no user sync.

    line 12
  2. Very specific:

    Clerk Organizations is the tenancy primitive: Every tenant-owned document carries an indexed orgId holding the Clerk org id (org_...) from …

    line 13
  3. Distinctive:

    If the repo doesn't have Clerk auth yet, create these pieces.

    line 20
  4. Very specific:

    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.

    line 179
  5. Very specific:

    Fail at boot on missing config: Keep the startup() secret-key check — a silently empty key surfaces as confusing 401s on the first request.

    line 182
  6. Very specific:

    Add all three to .env.example and turbo's globalEnv.

    line 192

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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)

  1. 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.
  2. Clerk Organizations is the tenancy primitive. Every tenant-owned document carries an indexed orgId holding the Clerk org id (org_...) from the session JWT. Every query on those models MUST include orgId.
  3. Protected by default. requireAuth is global middleware in main.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 above requireAuth to 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 thrown status: 403 error 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 with orgId (see backend-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. verifyBearerToken above 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's pre: [mw] option instead.
  • requireAdmin costs 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

VarWhere used
CLERK_SECRET_KEYbackend only; boot fails without it
VITE_CLERK_PUBLISHABLE_KEYwebapp (import.meta.env — MUST be VITE_-prefixed) and backend createClerkClient
CLERK_AUTHORIZED_PARTIESoptional 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=admin

For 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.