The admin credential is not stored in localStorage and is not a
cookie. It's a JWT stored in sessionStorage:
frontend/src/app/admin/login/page.tsx— on successfulPOST /api/admin/login, the returnedtokenis written tosessionStorage.setItem("admin_token", ...).frontend/src/services/allowlistService.ts— reads it back out ofsessionStorageand attaches it asAuthorization: Bearer <token>on subsequent admin API calls.frontend/src/app/admin/page.tsx— gates the admin page client-side on the presence of that samesessionStoragekey.
sessionStorage is scoped to a single tab and cleared when the tab closes
(no persistence across browser restarts, no sharing across tabs) — narrower
blast radius than localStorage, though both are equally readable by any
script running on the page.
CSRF exploits ambient credentials the browser attaches automatically
(cookies). Here, the admin token lives in sessionStorage and is only sent
because frontend JS explicitly reads it and sets an Authorization header —
a cross-site page cannot make the victim's browser attach it. No CSRF
protection is warranted as long as the credential stays out of a cookie.
Moving to an httpOnly cookie, as the issue's proposed solution suggests as
one option, would introduce CSRF exposure that doesn't exist today in
exchange for XSS-read protection the app doesn't otherwise need (see below).
Recommendation: keep the bearer-token-in-sessionStorage approach; do not
move to a cookie.
If an attacker achieves script injection, sessionStorage is readable by
that script just like localStorage — this risk isn't eliminated by the
current choice, only bounded (per-tab, session-lived, 8h JWT expiry so a
stolen token has a capped lifetime). The app already sets a restrictive CSP
(default-src 'none', script-src 'self') in backend/src/index.ts, which
is the actual first line of defense against the script injection this would
require.
The audit surfaced that the login flow was non-functional, independent of the CSRF question:
adminLoginRouter(backend/src/routes/adminLogin.ts) was never mounted on the Express app —POST /api/admin/login404'd. Fixed: mounted at/api/admin/logininbackend/src/index.ts(behindwriteLimiterto bound brute-force attempts against the admin secret).- The login route validated the submitted secret against
ADMIN_SECRET, an env var that doesn't exist anywhere else in the codebase or.env.example— it could never have been configured correctly. Fixed: validates againstADMIN_API_KEY, the same var already used by the rest of the admin-gating middleware. - The JWT issued at login was never verified by anything. The middleware
guarding admin routes (
requireAdminKey, used by allowlist/collaborators /provider/meters) only checked a staticX-Admin-Keyheader againstADMIN_API_KEY— it never looked at theAuthorization: Bearer <JWT>header the frontend actually sends after login. In practice, logging in via the UI produced a token that every subsequent admin request would reject as401 Unauthorized. Fixed:requireAdminKeynow also accepts a validAuthorization: Bearer <JWT>signed with the same secret and carryingrole: "admin", in addition to the existing directX-Admin-Keypath (kept for server-to-server callers).
- Storage:
sessionStorage, notlocalStorage— already the more conservative choice. - Session transport:
Authorizationheader, not a cookie — CSRF protection is not applicable and should not be added unless the transport changes to a cookie. - Mitigated: the login flow is now actually wired up and its issued session token is the credential enforced by admin routes.