Skip to main content
Core

Security standards

Security checklist for auth, data, API, infrastructure. Validate at edge, encrypt at rest, log everything.

Security standards

Purpose: Defines security requirements for authentication, input validation, multi-tenancy, and incident response. Every feature touching auth, data, or external systems must follow these standards.

How Framework protects users, data, and systems.

Quick reference:

  • Auth — Clerk middleware on all non-public routes, auth() check in every API route
  • Input — Zod validation at all system boundaries
  • Multi-tenancy — All queries scoped to orgId, RLS on all Supabase tables
  • Webhooks — Verify signatures before processing, validate timestamps, deduplicate by event ID
  • Cross-org access — Return 404 (not 403) to prevent enumeration
  • Secrets — Never in code/logs, rotate per schedule, 1Password is source of truth

Philosophy

Defense in depth. Fail secure.

Security is not optional. Every feature touching auth, data, or external systems must follow these standards.


Authentication (Clerk)

Middleware

// middleware.ts -- MUST be at project root
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/webhooks(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
};

API route protection

Every API route must verify auth:

import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { userId, orgId } = await auth();
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  if (!orgId) return NextResponse.json({ error: 'Organization required' }, { status: 403 });
  // Proceed with authorized request
}

For RBAC, check orgRole before sensitive operations (e.g., orgRole !== 'org:admin' for deletions).


Input validation

All inputs must be validated with Zod before processing. Use enums to prevent injection. Prisma parameterizes all queries by default -- never use $queryRawUnsafe with string interpolation. React escapes output by default -- never use dangerouslySetInnerHTML without DOMPurify.


Webhook security

Always verify webhook signatures before processing.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { logger } from '@/lib/logger';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    logger.error('Webhook signature verification failed', { error: err });
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Process verified event
  return NextResponse.json({ received: true });
}

Clerk webhooks use svix for verification -- same principle, verify before processing.

Timestamp validation and replay prevention

Webhook handlers must validate timestamps and deduplicate events:

// Reject webhooks older than 5 minutes
const timestamp = parseInt(headers.get('webhook-timestamp') ?? '0', 10);
const age = Math.abs(Date.now() / 1000 - timestamp);
if (age > 300) {
  return NextResponse.json({ error: 'Stale webhook' }, { status: 400 });
}

// Deduplicate by event ID (store processed IDs in Redis or DB)
const eventId = headers.get('webhook-id');
if (await isProcessed(eventId)) {
  return NextResponse.json({ received: true }); // Idempotent
}
await markProcessed(eventId);

API key management

Rotation schedule

Key Rotation Frequency Process
Linear API key Quarterly or on team change Regenerate in Linear Settings > API, update 1Password + Vercel env
Stripe keys Annually or on incident Roll in Stripe Dashboard, update env vars, verify webhook endpoints
Clerk keys Annually or on incident Regenerate in Clerk Dashboard, redeploy
Supabase service role key On incident only Regenerate in Supabase Dashboard, update all references

After rotation: redeploy all environments, verify webhook delivery, and check logs for auth failures.


CORS, rate limiting, and headers

CORS

API routes serving external clients must set explicit CORS headers. Never use Access-Control-Allow-Origin: * on authenticated endpoints.

Rate limiting

Public-facing endpoints (auth, webhooks, public APIs) should implement rate limiting. Use Vercel's built-in rate limiting or an edge middleware.

Content security policy

Add CSP headers in next.config.ts:

const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-eval' 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  img-src 'self' blob: data:;
  connect-src 'self' https://*.supabase.co https://*.clerk.dev;
`;

File upload validation

Validate file uploads at the edge:

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf'];

if (file.size > MAX_FILE_SIZE) throw new Error('File too large');
if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type');

Multi-tenancy

All queries must be scoped to the user's organization.

// Always filter by orgId
const doors = await prisma.door.findMany({
  where: { organizationId: orgId },
});

Unscoped queries return data from all organizations -- this is a data breach.

Supabase Row-Level Security (RLS)

When using Supabase directly (not via Prisma), enable RLS on all tables:

-- Enable RLS
ALTER TABLE doors ENABLE ROW LEVEL SECURITY;

-- Org-scoped read policy
CREATE POLICY "Users can view org doors" ON doors
  FOR SELECT USING (organization_id = auth.jwt() ->> 'org_id');

-- Org-scoped insert policy
CREATE POLICY "Users can create org doors" ON doors
  FOR INSERT WITH CHECK (organization_id = auth.jwt() ->> 'org_id');

-- Org-scoped update policy
CREATE POLICY "Users can update org doors" ON doors
  FOR UPDATE USING (organization_id = auth.jwt() ->> 'org_id');

-- Org-scoped delete policy (admin only)
CREATE POLICY "Admins can delete org doors" ON doors
  FOR DELETE USING (
    organization_id = auth.jwt() ->> 'org_id'
    AND auth.jwt() ->> 'org_role' = 'org:admin'
  );

Rules:

  1. RLS required — Every table with user data must have RLS enabled
  2. Org scoping — Every policy must include organization_id scoping
  3. Admin-only deletes — Delete policies should require admin role
  4. Service role key — Bypasses RLS; never expose it to the client

Cross-org authorization response

When an authenticated user requests a resource that belongs to a different organization, return 404 (not 403):

  • 404 — Obscures whether the resource exists; prevents enumeration attacks where an attacker probes valid IDs
  • 403 — Reveals the resource exists in another org; unnecessary information leakage

Exception: Return 403 when the resource's existence is already known to the requester (e.g., a shared invite link). In all other cases, default to 404.

// In API route handlers — cross-org resource access
const resource = await prisma.door.findFirst({
  where: { id: params.id, organizationId: orgId },
});
if (!resource) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// Note: returns 404 whether the resource doesn't exist OR belongs to another org

Sensitive data

Data Type Storage
Passwords Never stored (Clerk handles)
API keys Encrypted at rest (Vercel)
PII Database, org-scoped
Payment info Never stored (Stripe handles)

Security checklist for PRs

  • Auth check — At start of every API route
  • Input validation — Inputs validated with Zod
  • Org scoping — Queries scoped to user's organization
  • No secrets — In code or logs
  • No raw HTML — No dangerouslySetInnerHTML without sanitization
  • Webhook signatures — Verified before processing
  • Webhook timestamps — Validated (reject stale events)
  • File uploads — Validated (type, size) if applicable
  • CORS headers — Set explicitly on public API routes

Incident response

  1. Don't panic — Don't announce publicly
  2. Assess severity — Critical (data exposed, auth bypassed), High (exploitable, no breach), Medium (theoretical), Low (best practice)
  3. Critical/High — Disable affected feature, rotate compromised secrets, fix and deploy within hours
  4. Document — What happened, how discovered, what was fixed, how to prevent recurrence

If secrets are exposed: Rotate immediately, check logs, notify affected services, audit recent activity.


Related standards

  • standards/reference/environments.md -- Secrets management (1Password architecture)
  • standards/core/testing.md -- Test patterns for auth and validation

Framework Security Standards v3.0 -- March 2026

Search Framework Explorer

Search agents, skills, and standards