How the platform is defended. Read this before making changes that touch auth, data access, or user input.
- Authentication Model
- Authorization and Data Access
- Input Validation
- Rate Limiting
- Cryptography
- HTTP Security Headers
- File Upload Security
- Audit Logging
- Query Limits
- Service Tier Limits
- Attack Surface Summary
Three strictly separated auth flows. They must NEVER cross.
| Role | Method | Login Page | How it works |
|---|---|---|---|
| Admin | Google OAuth | /admin/login |
Google redirects to Supabase, Supabase redirects to /auth/callback. Email checked against admin_emails DB table + ADMIN_FALLBACK_EMAILS env var. |
| Local (chapter) admin | Google OAuth | /admin/login |
Same OAuth flow. When the email is NOT on the global allowlist, /auth/callback checks the chapter_admins table; a match grants a chapter-scoped session and redirects to that chapter. Accounts are pre-provisioned by inviteChapterAdmin, so the first OAuth login works despite signups being disabled. |
| Jury | Email magic link | /jury/login |
Supabase sends a magic link email. Clicking it authenticates via /auth/callback. Access checked against jury_assignments table. |
| Participant | Email + password | /login, /register |
Standard Supabase email/password auth. Registration requires email verification via encrypted code. |
- An attacker who compromises a participant account cannot access admin or jury functions
- Admin access requires both a valid Google account AND being on the email allowlist
- Local admins are invited explicitly (a
chapter_adminsrow written only via the service role) and are confined to one chapter; they never reach global admin views (see below) - Jury access requires an explicit admin invitation (no self-registration)
Local admins (role chapter_admin) are bounded by three independent layers, so a missing check in one does not grant cross-chapter access:
- Middleware (
lib/supabase/middleware.ts) allowschapter_admininto/admin, then hard-restricts them to/admin/chapters/<their-chapter>/**and/admin/check-in, redirecting any other/adminpath to their chapter. - Page guards — global pages use
requireGlobalAdminPage()(bounces local admins to their chapter); chapter pages userequireChapterAdminPage(id)(only their chapter). Write-heavy controls (edit/status/scores/jury/partners/challenges/local-admin management) are rendered only for global admins. - Action/API guards — the two writes a local admin may perform (
submitScore, the check-in actions) and the per-chapter read APIs they use callrequireChapterAdminAction(chapterId)/requireChapterAdminApi(chapterId), which authorize a global admin OR the local admin of that exact chapter. Every other admin action/route keeps the global-onlyrequireAdmin*guards.
The CV proxy (GET /api/admin/cv/[fileId]) is a special case: the request carries only a Drive fileId, not a chapter. The route therefore resolves the owning chapter server-side (looking up the applications row whose cv_url equals the fileId) and then calls requireChapterAdminApi(<owning chapter>). A local admin can read CVs only for applicants in their own chapter; a global admin reads any. A fileId not owned by any application is rejected (404) before the guard or Drive are touched, so the proxy cannot be used to pull arbitrary Drive files by id. The chapter is never taken from caller input, so it cannot be spoofed.
A few features exist only for the sim/preview deployment: dev-login (passwordless admin/jury/participant sessions, app/dev-login) and the code-review TEST routing (dispatches a separate worker that runs against the TEST database). All of them are gated by isDevLoginEnabled() (lib/dev-login.ts), which is default-safe and fail-closed on three independent layers:
- Default off — it returns
trueonly whenDEV_LOGIN_ENABLEDis exactly"true". Unset/empty/"false"⇒ off. Production never needs to set anything; leaving the var unset is the safe default (explicitly settingfalseadds nothing and just creates a prod-scoped knob). - Vercel-prod tripwire — if the flag is somehow
"true"whileVERCEL_ENV === "production", it throws instead of enabling. - Prod-database tripwire — if the flag is
"true"while the app is pointed at the production Supabase project (exact hostname match onNEXT_PUBLIC_SUPABASE_URLagainst the known prod ref, not a substring), it throws. This closes the gap whereVERCEL_ENVis not"production"(a non-Vercel or prod-like deployment) but the prod DB is in use — a test feature must never touch prod data regardless of where it runs.
Every entry point enforces this server-side (the dev-login page notFound()s, the action throws, the code-review dispatch picks its event type via the same gate), so UI hiding is never the control. Unit tests pin all three layers.
RLS is enabled on every table in the database. This is the primary data access control.
- Participant requests use the authenticated Supabase client (
createClient()fromlib/supabase/server.ts), which respects RLS policies - Admin operations use
createAdminClient()which bypasses RLS with the service role key - Read-only query modules (
lib/queries/) may usecreateAdminClient()for server-side data fetching when RLS would be too restrictive (e.g. public team pages reading member names). These are safe because they are server-only, read-only, and select only non-sensitive fields. - Rule: Never use
createAdminClient()for write operations in participant-facing code paths - Profiles table: RLS restricts reads to authenticated users only (
auth.uid() is not null, migration 00028). Anonymous API access (anon key without session) cannot read profiles. Fine-grained authorization (admin, jury, team membership) is enforced at the application level. - Chapter communications: the
chapterstable is publicly readable (status != 'draft'). RLS gates rows, not columns, so the admin/participant-only per-chapter communications text (acceptance email subject/message, event info) is NOT stored onchapters. It lives in a separate admin-only tablechapter_communications(no public read policy, migration 00052). Event info reaches participants only through the gatedgetChapterEventInfo()server action, which returns it solely to applicants of that chapter with statusaccepted/checked_in. - Walk-in registration token: an unguessable per-chapter UUID that lets a person register a walk-in (auto-accepted) and create an account at the event. For the same reason as chapter communications (RLS gates rows, not columns, and
chaptersis publicly readable), the token is NOT a column onchapters— it would leak via?select=walk_in_token. It lives in a separate admin-only tablechapter_walk_inwith no public/anon read policy (migration 00054, modeled on 00052). The public walk-in page resolves the chapter from the token via the service-rolegetWalkInChapterByToken()action, which looks a chapter up by token and never exposes the token list; a miss returns null uniformly (no existence oracle). Defenses onsubmitWalkInApplication():- Token gate replaces the status gate. The token (not
status === 'applications_open') authorizes the submission, so walk-ins work during hacking/submissions_open; onlydraft/completedchapters are refused for hygiene. - Existing-account refusal. If the email already has a profile, the action refuses with "sign in first" and creates nothing — it never makes a second account and never touches the existing password.
- Rate limiting. Limited by IP (shared event WiFi uses the generous application limit) AND per walk-in token (
walkInTokenLimiter, 60/min/token) so a leaked QR cannot be scripted into mass account creation. - Rotation.
rotateWalkInToken()(chapter-admin guarded) issues a fresh UUID, invalidating any previously printed QR. Turnstile guards the public submission like other public forms.
- Token gate replaces the status gate. The token (not
- Partner showcase token: an unguessable per-chapter UUID that lets a sponsor view that match's consented applicants, CVs, teams/ranking, and photos without logging in. For the same reason as the walk-in token, it is NOT a column on
chapters— it lives in the admin-onlychapter_partner_showcasetable with no public/anon read policy (migration 00060, modeled on 00054). The public page resolves the chapter via the service-rolegetShowcaseByToken()action, which returns null uniformly on any miss — no such token, disabled, or expired are indistinguishable (no existence oracle). Defenses:- Consent is the hard gate. Applicants appear only if they opted into sponsor sharing (
consent_sponsor_dataORconsent_recruiting). The predicate (hasSponsorConsent/SPONSOR_CONSENT_OR_FILTER) is applied both as a DB.or()filter and re-asserted in code (defence in depth), and is shared by the list and the CV proxy so a hidden applicant can never have their CV fetched. - Explicit enablement + expiry.
is_enableddefaults false (an unshared showcase 404s),show_cvsdefaults false (CV access is opt-in per chapter), and an optionalexpires_athard-expires the link. - CV proxy is chapter-scoped + keyed by application id.
/api/showcase/<token>/cv/<applicationId>resolves the chapter from the token, thengetShowcaseCvFileId()returns the Drive file id only if the application belongs to THAT chapter and passes consent — so a token for chapter A cannot fetch a CV from chapter B (IDOR guard). Drive file ids never appear in URLs. CVs stream withCache-Control: no-store,X-Robots-Tag: noindex,Referrer-Policy: no-referrer,nosniff. - Rate limiting + no token leakage. The CV route is IP-rate-limited (
showcaseLimiter) to blunt scripted mass-download/enumeration. The page isnoindexand setsReferrer-Policy: no-referrer; outbound profile/CV links carryrel="noreferrer"so the token in the URL cannot leak via the Referer header. - Rotation.
rotateShowcaseToken()(chapter-admin guarded) issues a fresh UUID, immediately revoking the previously shared link. - Bulk-CV ZIP (
/api/showcase/<token>/cvs): same gating chain as the single-CV proxy (live token via the rate-limited resolver,show_cvson, consent-gated CV list with in-code re-check), PLUS a dedicated tight limiter (showcaseZipLimiter, 3/10min per IP) because one archive fans out to two Drive API calls per CV. Over-cap requests are refused with 413 before any bytes stream (never a silently truncated archive); mid-stream Drive failures are skipped and listed in a_MANIFEST.txt. Same no-store/noindex/no-referrer hygiene.
- Consent is the hard gate. Applicants appear only if they opted into sponsor sharing (
Every server action follows this pattern:
1. Check authentication (is user logged in?)
2. Check authorization (is user allowed to do this?)
3. Validate input
4. Perform operation
5. Return result
- Admin actions call
requireAdminAction()first (global admins only) - Chapter-scoped actions (screening, check-in) call
requireChapterAdminAction(chapterId)— passes a global admin or the local admin of that chapter - Participant actions verify the user owns the resource (e.g. is a member of the team)
- Jury actions verify the user is assigned to the relevant challenge
- Admin API routes check the session and email allowlist (
requireAdmin()); per-chapter routes a local admin may read userequireChapterAdminApi(chapterId) - Cron routes use Bearer token auth (
CRON_SECRET) - Certificate routes accept EITHER session auth (team member or admin) OR an unguessable capability token bound to the exact
(chapterId, teamId)pair (see Cryptography below); they only ever serve published scores. The token path lets emailed certificate links open without login while preventing enumeration of other teams' certificates. - Jury API routes check both session and role (jury or admin)
| Boundary | What's validated |
|---|---|
| Server actions | Type checks, length limits, allowed values. All user input at the entry point. |
| API routes | Request body parsing, parameter validation |
| Turnstile | CAPTCHA on all public forms (login, register, apply, password reset, jury login) |
| Database constraints | Unique constraints, foreign keys, NOT NULL |
- Any data from
paramsorsearchParams(URL manipulation) - Form data from client components
- File uploads (MIME type validated server-side)
- Redirect targets from user input (must start with
/and not//) - Entire session-history content (prompts/transcripts on
entire/checkpoints/v1). Prompt text is fully participant-controlled and a prompt-injection vector into the code-review pipeline. The session-history reviewer wraps prompts as untrusted<session-prompt>data and instructs the model to evaluate, never obey, any embedded instructions (mirroring the existing code-context hardening).
When a challenge requires Entire (entire.io), the code-review pipeline reads the entire/checkpoints/v1 branch. Key properties and decisions:
- Capture into the private fork only. The checkpoint branch is copied into the private EHL snapshot org (
lib/github.ts: fetchCheckpointBranchIntoFork), not left on any public path. This matters because Entire stores code-file snapshots as raw blobs without redaction, and secret redaction for prompts/transcripts is best-effort only. Keeping the data in the private fork limits exposure of participant prompts and any secrets Entire failed to redact. - The presence gate is not anti-cheat. Entire capture is client-side and bypassable (
--no-verify, working outside the repo, deleting the branch before push). The hard gate only proves a session record exists; it cannot prove it is complete or untampered. The session-history bonus is therefore advisory and cross-checked against the code, never a standalone integrity verdict (matches SOTA: low-weight, triangulated signal). - Soft, version/agent-tolerant parsing.
lib/entire.tsnever hard-parses agent-specific transcript formats and never fails the gate on a malformed file; it accepts any positive signal. This avoids penalizing teams for tool choice or Entire-version quirks. - Signing as a trust booster. If checkpoint commits are GPG/SSH-signed (GitHub-verified), this raises the completeness/plausibility assessment. Absence of a signature is not penalized.
Powered by Upstash Redis. All limiters use sliding window algorithm.
| Limiter | Limit | Window | Protects |
|---|---|---|---|
authLimiter |
5 requests | 60 seconds | Login, password reset |
registerLimiter |
3 requests | 60 seconds | Registration |
resetLimiter |
3 requests | 60 seconds | Password reset |
applicationLimiter |
3 requests | 60 seconds | Application submission |
uploadLimiter |
10 requests | 1 hour | File uploads |
emailLimiter |
3 per address | 1 hour | Email sending (per recipient) |
apiLimiter |
1,000 requests | 60 seconds | General API endpoints (high: 500+ participants share one WiFi) |
certLimiter |
10 requests | 60 seconds | Certificate PDF generation (CPU-intensive) |
If Redis is unavailable (connection error, quota exceeded), an in-memory sliding window takes over with per-limiter fallback limits that match the Redis configuration. Auth-sensitive endpoints (login, registration, password reset) use the same strict limits as Redis (3-5/min). The API limiter uses a reduced limit (100/min instead of 1000/min). Fallback activation is logged via console.warn for monitoring. The in-memory store is not shared across serverless instances, so it provides best-effort protection only.
Upstash free tier: 10,000 commands/day. At ~500 users, auth flows use ~2,000-5,000 commands/day. If rate limiting silently stops working, check the Upstash dashboard for quota exhaustion.
During registration, users receive a verification code by email. The temporary password is encrypted server-side before storage:
- Algorithm: AES-256-GCM
- Key:
VERIFICATION_ENCRYPTION_KEYenv var (falls back toSUPABASE_SERVICE_ROLE_KEY) - Implementation:
lib/crypto.ts - Plaintext passwords are NEVER stored, not even temporarily
Certificate PDFs are emailed to participants who may not be logged in. The route
/api/certificates/[chapterId]/[teamId] is therefore reachable without a session,
but only with a valid capability token:
- Algorithm: HMAC-SHA256 over
${chapterId}:${teamId}, base64url-encoded - Key:
CERTIFICATE_LINK_SECRETenv var (falls back toVERIFICATION_ENCRYPTION_KEY); SHA-256-derived and label-namespaced so it never collides with the AES key above - Implementation:
lib/certificate-token.ts - Verification: the route recomputes the expected token and compares with
crypto.timingSafeEqual(length-checked first to avoid an exception oracle) - Scope: a token authorizes ONLY its own certificate. It is bound to both ids, so team A's link cannot reveal team B (the HMAC for a different pair differs and cannot be forged without the secret). chapterId/teamId remain effectively unguessable to outsiders.
- No DB/migration: stateless. Rotating the secret invalidates all previously emailed links at once; logged-in admins/members are unaffected (their session path is unchanged). There is no token expiry by design; the link's only sensitive content is the team's own already-published certificate.
- The session path (admin or team member) is preserved unchanged, and the existing
certLimiterrate limit now runs on every request before any auth branch, so the token path is rate-limited too.
- Passwords are hashed by Supabase (bcrypt)
- JWTs are signed by Supabase with the project's JWT secret
- Session tokens are stored in HTTP-only cookies via
@supabase/ssr
Applied to all routes via next.config.ts:
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options |
DENY |
Prevents clickjacking |
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer information |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Forces HTTPS for 2 years |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disables sensitive browser APIs |
default-src 'self';
script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: <SUPABASE_URL>;
font-src 'self';
connect-src 'self' <SUPABASE_URL> https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com https://www.youtube.com https://www.youtube-nocookie.com;
object-src 'none';
base-uri 'self'
The Supabase URL is injected dynamically from NEXT_PUBLIC_SUPABASE_URL at build time (see next.config.ts).
Allowed external domains:
- Supabase project URL: Database API calls, image hosting
challenges.cloudflare.com: Turnstile CAPTCHA widgetyoutube.com,youtube-nocookie.com: Embedded videos
Only these file types are accepted for image uploads:
image/pngimage/jpegimage/webpimage/avif
SVG is explicitly blocked (XSS risk via embedded scripts).
| Upload type | Storage | Size limit |
|---|---|---|
| CVs (application) | Google Drive | 4MB (server action body limit) |
| Submission files | Google Drive | 20MB (API route, validated server-side) |
| Partner logos | Supabase Storage | 4MB (server action body limit) |
| Chapter photos | Google Drive | 4MB (server action body limit) |
| Challenge briefs | Google Drive | 4MB (server action body limit) |
- MIME type checked server-side before storage
- File size enforced by Next.js server action body limit (4MB) or API route validation (20MB for submissions)
- Filenames sanitized before storage
All admin actions are logged to the admin_audit_log table:
| Column | Description |
|---|---|
action |
What happened (e.g. update_chapter_status, publish_scores, send_certificates) |
entity_type |
What was affected (e.g. chapter, team) |
entity_id |
ID of the affected entity |
performed_by |
User ID of the admin |
details |
JSON with context (previous values, etc.) |
created_at |
Timestamp |
All admin state-changing operations are audit logged via a shared auditLog() helper in lib/actions/admin.ts. The audit log table has an immutability policy (migration 00025): rows cannot be updated or deleted.
State-changing actions across the app are also recorded in the append-only,
hash-chained event_log via lib/event-log.ts (logEvent() for non-critical
actions, logEventStrict() for score/jury-critical ones). Each row carries an
actor_type (admin | participant | jury | system) and an actor_id.
Actor-integrity guarantee: every admin/participant/jury event MUST record the
acting account. An audit row that says "this was changed" but not "by whom" is
worthless, so this is enforced in lib/event-log.ts:
actorIntegrityError()treats a null/emptyactor_idon any non-systemevent as a violation.logEventStrict()THROWS on a violation, so a score/jury action with no actor is never accepted (and surfaces immediately in dev/tests/CI).logEvent()(fire-and-forget) logs a loud server error on a violation but still writes the row. This is a deliberate backstop: it must never regress the earlier fix that stopped dropping audit events, so losing the row entirely is not an option. The real defense is that every call site resolves the acting user before logging.- Only
systemevents (cron/deadline automation, public client error reports, and public application submissions where the applicant has no account yet) may legitimately have noactor_id.
Admin call sites resolve the acting account via getActingUserId() (centralized
in lib/admin-auth.ts), which reads the same authenticated server client the
require*Action guards use, so a successful admin guard always yields a usable
id. Participant and jury actions pass their own user id.
Every database query that could return unbounded rows has a configurable limit. This prevents:
- Memory exhaustion from large result sets
- Slow queries from scanning too many rows
- Accidental data exposure
- All limits are defined in
lib/config/limits.tsasQUERY_LIMITS - Each limit has a default value and an env var override (e.g.
LIMIT_TEAMS=500) - When a query hits its limit, the UI shows a yellow
LimitBannerwarning - Users are NEVER shown silently truncated data
| Limit | Default | Env var |
|---|---|---|
| Teams | 500 | LIMIT_TEAMS |
| All team members | 2,500 | LIMIT_ALL_TEAM_MEMBERS |
| Profiles | 1,000 | LIMIT_PROFILES |
| Applications per chapter | 2,000 | LIMIT_APPLICATIONS_PER_CHAPTER |
| Application stats | 5,000 | LIMIT_APPLICATION_STATS |
| Screening scores | 5,000 | LIMIT_SCREENING_SCORES |
| Scores | 1,000 | LIMIT_SCORES |
| Leaderboard | 500 | LIMIT_LEADERBOARD |
| Media | 200 | LIMIT_MEDIA |
| Submissions per challenge | 200 | LIMIT_SUBMISSIONS_PER_CHALLENGE |
| Code reviews per challenge | 200 | LIMIT_CODE_REVIEWS_PER_CHALLENGE |
| Chapter unlocks | 500 | LIMIT_CHAPTER_UNLOCKS |
| Challenge registrations | 500 | LIMIT_CHALLENGE_REGISTRATIONS |
| Users looking for team | 500 | LIMIT_USERS_LOOKING_FOR_TEAM |
| Code review queue depth | 200 | LIMIT_CODE_REVIEW_QUEUE_DEPTH |
Current limit values are visible at Admin > Settings > Query Limits with instructions on how to change them.
External service limits that affect the platform. If you hit unexplained errors (timeouts, 429s, storage full), check these first.
- Function timeout: 60s (was 10s on Hobby)
- Bandwidth: 1TB/mo
- Serverless function executions: unlimited
- Database: 500MB storage
- Auth: 50,000 monthly active users
- Storage: 1GB file storage
- API requests: unlimited but 500 concurrent connections
- Realtime: 200 concurrent connections
- Edge functions: 500,000 invocations/mo
- Watch out: Database approaching 500MB triggers warnings
- Commands: 10,000/day
- Storage: 256MB
- Watch out: At 500 users, auth flows use ~2,000-5,000 commands/day. If rate limiting stops working silently, check the daily command limit.
- 1M siteverify calls/mo (effectively unlimited for our scale)
- OAuth: Unlimited in production mode (100 in testing mode)
- Gmail API: 250 quota units/second
- No rate limit, cost scales with usage
- ~$0.10-0.30 per code review
- Budget for 100 reviews/chapter: ~$10-30
- API rate limit: 5,000 requests/hour (authenticated)
- Actions: 2,000 minutes/mo (free for public repos)
| Attack vector | Defense |
|---|---|
| Brute-force login | Rate limiting (5/min) + Turnstile CAPTCHA |
| Brute-force registration | Rate limiting (3/min) + Turnstile + email verification |
| SQL injection | Supabase client parameterizes all queries |
| XSS | CSP headers + React's built-in escaping + SVG upload blocked |
| CSRF | Supabase uses SameSite cookies + server actions use POST |
| Clickjacking | X-Frame-Options: DENY |
| Open redirect | Redirect targets validated (must start with /, not //) |
| Privilege escalation | RLS on all tables + server-side auth checks + separated auth flows + DB trigger blocks role changes (migration 00030) |
| Late submission | is_locked + real-time deadline check enforced at RLS level (migrations 00031, 00035) + application-level deadline check |
| Multi-team during active chapter | DB trigger serializes concurrent inserts via row locks (migration 00035) + application-level pre-check |
| Team membership enumeration | RLS restricted to authenticated users (migration 00035), server-side queries use admin client |
| Code review prompt injection | User code wrapped in XML tags with explicit untrusted data warnings in system prompts |
| Data scraping | Rate limiting on API + query limits on all queries |
| Email enumeration | Rate limiting on account/team lookup server actions |
| Email bombing | 3 emails/hour per address rate limit |
| File upload abuse | MIME whitelist + size limits (4MB server actions, 20MB submissions) + rate limiting |
| JWT manipulation | JWTs signed by Supabase, validated server-side |
| Admin impersonation | Google OAuth only + email allowlist (DB + env var) |
| Jury vote manipulation | INSERT-only voting (no updates after submission) |
| Audit log tampering | Immutable audit log (no UPDATE/DELETE policy) |