DocsArchitectureSecurity

Security

Security boundaries, authentication, authorization, and data protection in Reqcore. Covers tenant isolation, input validation, document security, and headers.

Security

Reqcore enforces security at multiple layers: authentication, tenant isolation, input validation, document access, and HTTP headers.

Security Boundaries

BoundaryEnforcement
AuthenticationrequireAuth(event) — throws 401 if no session
Organization membershipBetter Auth org plugin — users can only access orgs they belong to
Tenant data isolationEvery query includes eq(table.organizationId, orgId)
Input validationZod schemas via readValidatedBody / getValidatedQuery
Rate limitingcreateRateLimiter() on public endpoints (sliding window by IP)
Document accessServer-proxied streaming — no presigned URLs
Security headersGlobal Nitro route rules

Authentication

Reqcore uses Better Auth for session-based authentication:

  • Sessions are stored server-side and identified by an HTTP-only cookie
  • Auth routes are handled by a catch-all at server/api/auth/[...all].ts
  • Client-side auth is managed via app/utils/auth-client.ts

Route Protection

// Server: Protect an API route
export default defineEventHandler(async (event) => {
  const { session, user } = await requireAuth(event)
  const orgId = session.activeOrganizationId
  // All queries scoped to orgId
})
// Client: Redirect unauthenticated users
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  // Redirects to /auth/sign-in if not authenticated
})

Multi-Tenant Isolation

The #1 security invariant: The organization ID is always derived from the authenticated session — never from user input (request body, query parameters, or URL params).

Request → Auth Guard → Extract orgId from session → Scope all queries by orgId

This prevents:

  • Cross-tenant data access
  • Escalation attacks via URL manipulation
  • Data leakage between organizations

Input Validation

All API endpoints validate input using Zod schemas:

// Server: Validate request body
const body = await readValidatedBody(event, createJobSchema.parse)

// Server: Validate query parameters
const query = await getValidatedQuery(event, listJobsSchema.parse)

Validation schemas are defined in server/utils/schemas/ and shared between validation and TypeScript type inference.

Document Security

MeasureImplementation
Private S3 bucketPolicy enforced on every startup — deletes any public access rules
Server-proxied accessDownload and preview endpoints stream from S3; no presigned URLs exposed
MIME validationMagic byte inspection (not just Content-Type header)
Filename sanitizationsanitizeFilename() strips path traversal, XSS payloads, unsafe characters
Storage key hiddenstorageKey (S3 object key) is filtered from all API responses
Document limitsMax 20 documents per candidate on public endpoints
PDF-only previewOnly application/pdf can be previewed inline
Cache headersCache-Control: private, no-store on all document endpoints

Rate Limiting

Public endpoints are protected by IP-based sliding window rate limiting:

  • Implementation: server/utils/rateLimit.ts
  • Scope: Applied to public application submission and authentication endpoints
  • Algorithm: Sliding window per IP address
  • Storage: In-memory (single instance)

HTTP Security Headers

Global security headers are applied via Nitro route rules:

// Applied to all routes
{
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'X-XSS-Protection': '1; mode=block',
  'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
}

Exception: Document preview endpoints use X-Frame-Options: SAMEORIGIN to allow inline PDF rendering in the application.

Search Engine Isolation

Private pages are blocked from search engine crawling:

  • Dashboard, auth, onboarding, and API routes are disallowed in robots.txt
  • Private pages include noindex, nofollow meta tags via useSeoMeta()

Recommendations for Self-Hosting

  1. Use HTTPS — Put a reverse proxy (Nginx, Caddy) with TLS in front of the application
  2. Change all default passwords — Run setup.sh to generate secrets
  3. Keep Docker ports local — Ports are bound to 127.0.0.1 by default; don't expose them
  4. Back up your data — Schedule pg_dump for PostgreSQL and mc mirror for S3
  5. Keep up to date — Pull the latest code and run migrations regularly

Next Steps