Security & Middleware

The EGAC platform is a public-facing site handling personal data (names, DOBs, email addresses) and one-time tokens. This document covers the security controls applied at each layer.

Middleware pipeline

Every request through Astro SSR passes through src/middleware/index.ts:

flowchart TD A[Incoming request] --> B{Is public API route?\n/api/enquiry\n/api/booking\n/api/academy/respond} B -->|Yes| C{OPTIONS preflight?} C -->|Yes| SKIP[Skip rate limiting] C -->|No| D{KV available?} D -->|Yes| E[Build bucket key\nratelimit:IP:YYYY-MM-DDTHH:MM] E --> F[GET count from KV] F --> G{count >= 10?} G -->|Yes| H[Return 429\n+ security headers] G -->|No| I[PUT count+1 TTL=70s] D -->|No| SKIP B -->|No| SKIP SKIP --> J[next — pass to handler] I --> J J --> K[Add security headers\nto all responses] K --> L[Return response]

Rate limiting

Applied to public API routes only. Protects against form-spam and credential stuffing.

SettingValue
Routes/api/enquiry, /api/booking, /api/academy/respond
Limit10 requests per minute per IP
Window60-second minute bucket
StorageCloudflare KV with TTL = 70 seconds
IP sourceCF-Connecting-IP header (set by Cloudflare)

The key format is ratelimit:{ip}:{YYYY-MM-DDTHH:MM} — one KV entry per IP per minute. Rate limiting failures (e.g. KV unavailable) are caught and logged but never block legitimate requests.

Security headers

Applied to every response:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
Content-Security-PolicySee below

Content Security Policy

default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://esm.sh;
style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net;
font-src 'self';
img-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';

frame-ancestors 'none' reinforces the X-Frame-Options: DENY header for browsers that support CSP Level 2.

Admin authentication

Admin routes use a token passed via one of three mechanisms, checked in order:

  1. Cookie: egac_admin_token (HttpOnly, set on login)
  2. Authorization header: Bearer <ADMIN_TOKEN>
  3. Legacy header: x-admin-token
export function checkAdminToken(token: string, env: Env): boolean {
  if (!token) return false;
  // 'dev' only works in development — never in staging or production
  if (token === 'dev') return env.APP_ENV === 'development';
  return token === env.ADMIN_TOKEN;
}

The dev shorthand is explicitly restricted to APP_ENV === 'development'. A misconfigured production environment that somehow sets ADMIN_TOKEN=dev would still be secure because the APP_ENV check would reject it.

Token security

Booking invite tokens

  • 48-character hex string (24 random bytes via crypto.getRandomValues)
  • Stored in invites.token with a unique constraint
  • 14-day TTL enforced at both HTTP layer (checked in the booking handler) and by the expire-invites cron

Academy waitlist tokens

  • Same 48-char hex format
  • Used in /academy/respond/:token — unique per waitlist entry

Membership OTP tokens

  • 64-character hex string (32 random bytes)
  • 7-day TTL
  • Single-use: used_at is set when the form is submitted; re-submission is rejected

Environment isolation

Every row in enquiry-related tables carries an environment column. Admin list queries always filter by env.APP_ENV, preventing test data leaking into production views:

SELECT * FROM enquiries WHERE environment = ? -- bound to APP_ENV

This means a development or staging database can safely share the same D1 instance as production without data contamination in admin views.

Secret management

Secrets are never in code or wrangler.toml. They are set via Wrangler:

wrangler secret put RESEND_API_KEY
wrangler secret put ADMIN_TOKEN
wrangler secret put CRON_SECRET

Non-secret environment variables (APP_ENV, EMAIL_FROM) are in the [vars] section of wrangler.toml and are safe to commit.

Error handling

The handleApiError() function in src/lib/errors.ts ensures:

  • Known errors (validation, auth, not-found, rate-limit) return their typed HTTP status with a structured body
  • Unknown errors log the full error internally but return only { "error": "Internal server error", "code": "INTERNAL_ERROR" } — stack traces and internal details are never exposed
  • Rate limit responses include a Retry-After header with the window duration in seconds
info
All structured logs use a consistent { level, event, ...context } JSON format readable in the Cloudflare Pages Functions log viewer. No PII is included in log event names — only IDs.