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:
Rate limiting
Applied to public API routes only. Protects against form-spam and credential stuffing.
| Setting | Value |
|---|---|
| Routes | /api/enquiry, /api/booking, /api/academy/respond |
| Limit | 10 requests per minute per IP |
| Window | 60-second minute bucket |
| Storage | Cloudflare KV with TTL = 70 seconds |
| IP source | CF-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:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Content-Security-Policy | See 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:
- Cookie:
egac_admin_token(HttpOnly, set on login) - Authorization header:
Bearer <ADMIN_TOKEN> - 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.tokenwith 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_atis 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-Afterheader with the window duration in seconds
{ level, event, ...context } JSON format readable in the Cloudflare Pages Functions log viewer. No PII is included in log event names — only IDs.