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:
- RLS required — Every table with user data must have RLS enabled
- Org scoping — Every policy must include
organization_idscoping - Admin-only deletes — Delete policies should require admin role
- 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
dangerouslySetInnerHTMLwithout 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
- Don't panic — Don't announce publicly
- Assess severity — Critical (data exposed, auth bypassed), High (exploitable, no breach), Medium (theoretical), Low (best practice)
- Critical/High — Disable affected feature, rotate compromised secrets, fix and deploy within hours
- 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