src/proxy.js (not middleware.js). It runs on the edge matcher below before App Router pages and route handlers.
/, /about, /login) is in the matcher so a New York geo deny can cover them, but those paths skip the session redirect (otherwise /login would loop). /unavailable is also in the matcher so the proxy can let it through after a geo deny; it skips both the geo redirect and the session gate. GET /api/public/* returns immediately without a session.
Request path
Super Admin page prefixes (level 5)
Super Admin page prefixes (level 5)
/admin/questions, /admin/logs, /admin/system, /admin/goals, /admin/submissions, /admin/schoolsSuper Admin API prefixes (level 5)
Super Admin API prefixes (level 5)
/api/admin/questions, /api/admin/health, /api/admin/goals, /api/admin/forms/rollover, /api/admin/forms/live, /api/admin/forms/export, /api/admin/forms/populate-bank, /api/admin/forms/migrate-contacts, /api/admin/schoolsSchool admin (level ≥ 4)
School admin (level ≥ 4)
Other
/admin/* and /api/admin/* paths (for example /admin/users and /api/admin/users/school) require a principal or Super Admin JWT. Route handlers still reload User from MongoDB for the real permission check.src/lib/formAccess.js and each route handler. See Roles and access.
New York geo restriction
Vercel setsx-vercel-ip-country and x-vercel-ip-country-region on requests that hit their network. The proxy reads those in src/lib/geoRestrict.js.
- Allowed: US + region NY (New York State). Do not match
geo_cityto"New York"— Brooklyn, Queens, the Bronx, and Staten Island IPs usually geolocate to the borough name and would be locked out. - Missing headers (local
next start) are treated as allowed. - App enforcement is off unless
GEO_RESTRICT=deny. WAF log/challenge rules sit in front of the process; the proxy only hard-denies. - HTML denials redirect to
/unavailable(branded page, no API calls). API denials stay 403 JSON.
JWT deny-list
Every JWT gets ajti (UUID). On signOut, NextAuth writes sess:deny:{jti} in Redis until the token would have expired. Proxy and the JWT callback treat a denied jti as unauthenticated.
Without Redis, logout cannot revoke the cookie early. The session still expires at maxAge (8 hours).
HTTP headers
next.config.js sets these on /:path*:
CSP
script-src includes 'unsafe-inline' (Next.js / Once UI). 'unsafe-eval' was removed. Do not loosen frame-ancestors or form-action.
Indexing
metadataBase follows NEXTAUTH_URL.
OAuth hardening
Google provider insrc/lib/auth.js:
hd: 'schools.nyc.gov'so the account picker prefers DOE Workspaceprompt: 'select_account'checks: ['pkce', 'state', 'nonce']- Secure cookies when
NEXTAUTH_URLishttps://
User with that email. hd is not a substitute for the MongoDB allowlist.