Skip to content

API Security Model

apps/webapp uses a defense-in-depth authentication model for /api/* routes implemented across three layers:

  1. Explicit route-prefix categorization in apps/webapp/src/middleware.ts β€” categorizes routes into Public, Admin, Organization-Required, and Auth-Only prefixes.
  2. Uncategorized-route enforcement in apps/webapp/src/middleware/api-auth-enforcement.ts β€” evaluates any /api/* route against apps/webapp/src/lib/api/auth-allowlist.ts (PUBLIC_API_EXACT_PATHS and PUBLIC_API_PREFIX_PATHS).
  3. Handler-level guards in BaseAPIHandler (apps/webapp/src/lib/api/base-handler.ts) β€” route handlers explicitly call requireAuth(), requireAdmin(), requireOrganization(), or validateInternalServiceToken().

1. Public API Routes (PUBLIC_API_ROUTES & auth-allowlist.ts)

Section titled β€œ1. Public API Routes (PUBLIC_API_ROUTES & auth-allowlist.ts)”

Routes that handle their own authentication (e.g., webhook signatures, service tokens, OAuth 2.1 / PKCE flows) or are intentionally public health probes:

  • /api/health (and /api/health/ready, /api/health/redis, /api/health/schema) β€” infrastructure & schema health probes
  • /api/contact β€” public contact form submissions
  • /api/webhooks & /api/webhook/* β€” Clerk, Stripe, inbound email, forwarding confirmation, and Zendesk webhooks (verified via HMAC signatures or validateInternalServiceToken)
  • /.well-known/*, /api/oauth/*, /api/mcp/* β€” OAuth 2.1 Authorization Server metadata, JWKS, Dynamic Client Registration, and token endpoints for ops-mcp
  • Internal service-token routes in PUBLIC_API_PREFIX_PATHS (/api/alias/, /api/email/triage, /api/eval/, /api/inbox-routing/internal/, /api/orders/missed-reconciliation/, /api/billing/internal/) β€” guarded by header secrets via validateInternalServiceToken

Require an authenticated Clerk session belonging to a system administrator (isUserAdmin(user)):

  • /api/admin/* β€” system administration
  • /api/sys-admin/* β€” platform operator controls
  • /api/cache/* β€” Redis cache inspection and flushing
  • /api/maintenance/* β€” maintenance operations
  • /api/debug/* β€” diagnostic endpoints
  • /api/tests/* β€” test harness endpoints

3. Organization-Required API Routes (ORG_REQUIRED_API_ROUTES)

Section titled β€œ3. Organization-Required API Routes (ORG_REQUIRED_API_ROUTES)”

Require both an authenticated Clerk user session and an active organization context (clerk_organization_id), verified against organization membership:

  • /api/erp/* β€” ERP connections, catalog search, quote generation, and order submission
  • /api/orders/* β€” order CRUD, revisions, and feedback
  • /api/extracted-orders/* β€” extracted order review state
  • /api/pdf-documents/* β€” document upload and retrieval. Exception: POST /api/pdf-documents itself is runtime-public (apps/webapp/src/lib/api/auth-allowlist.ts) because it is mixed-auth: source=manual calls requireAuthFromLocals, while source=external (the Cloudflare email worker) has no Clerk session and is authenticated by an X-Inbound-Email-Signature HMAC over {filename, connectionId, organizationId}, whose organizationId must own the connection. Sub-routes such as /api/pdf-documents/[id] are not allowlisted and do require a session.
  • /api/inbox/* β€” unified inbox queries and bulk actions
  • /api/emails/* β€” email threads, connections, and labels
  • /api/alias/* β€” organization email aliases
  • /api/billing/* & /api/subscriptions/* β€” Stripe billing and usage analytics
  • /api/organizations/* & /api/settings/org-rules/* β€” organization settings and routing rules
  • /api/temporal/workflow/* β€” workflow status polling
  • /api/analytics/* & /api/analytics-admin/* β€” product and operational metrics
  • /api/audit/*, /api/notifications/*, /api/support/*

Require an authenticated Clerk user session, with organization scoping resolved inside the handler:

  • /api/auth/* β€” session and user profile endpoints
  • /api/work-items/* β€” work-item state transitions and snooze controls

Uncategorized API Route Enforcement (AUTH_ENFORCEMENT_MODE)

Section titled β€œUncategorized API Route Enforcement (AUTH_ENFORCEMENT_MODE)”

apps/webapp/src/middleware/api-auth-enforcement.ts runs on every /api/* request not matched by PUBLIC_API_EXACT_PATHS or PUBLIC_API_PREFIX_PATHS:

  • AUTH_ENFORCEMENT_MODE=log (current default): If no authenticated locals.userId is present, the middleware logs a structured warning (AUTH_ENFORCEMENT: would block) with { path, method } and passes the request to the route handler (return next()).
  • AUTH_ENFORCEMENT_MODE=enforce: Immediately rejects unauthenticated requests at the middleware boundary with HTTP 401:
    {
    "error": "Unauthorized"
    }
  • AUTH_ENFORCEMENT_MODE=off: Disables the enforcement check.

class AdminHandler extends BaseAPIHandler {
async GET({ locals, request }: APIContext): Promise<Response> {
return this.handleRequest(async () => {
const { userId } = await this.requireAdmin(locals, request);
// Admin-only logic...
});
}
}
class OrderHandler extends BaseAPIHandler {
async POST({ locals, request }: APIContext): Promise<Response> {
return this.handleRequest(async () => {
const { userId, organizationId } = await this.requireOrganization(locals, request);
// Every DB query MUST filter by organizationId (clerk_organization_id)
});
}
}

Routes called by temporal-worker, ts-temporal-worker, or email-worker must validate a shared service token using constant-time comparison (validateInternalServiceToken in apps/webapp/src/lib/api/validate-internal-token.ts):

import { validateInternalServiceToken } from '@/lib/api/validate-internal-token';
const authResult = validateInternalServiceToken(request, process.env.INTERNAL_SERVICE_TOKEN);
if (!authResult.valid) {
return new Response(JSON.stringify({ error: authResult.error }), { status: authResult.status });
}

  1. Choose the auth model:
    • User-facing tenant route β†’ add prefix to ORG_REQUIRED_API_ROUTES in apps/webapp/src/middleware.ts and call requireAuth / requireOrganization in the handler.
    • System admin route β†’ add prefix to ADMIN_API_ROUTES and call requireAdmin.
    • Worker/webhook route β†’ if it uses its own signature or service token, register the path in apps/webapp/src/lib/api/auth-allowlist.ts and verify the signature/token at the top of the handler.
  2. Scope every query by tenant: Always include eq(table.clerkOrganizationId, organizationId) or eq(table.connectionId, connectionId).
  3. Add tests: Include an unauthenticated 401 test and a cross-organization 403/404 isolation test.