Skip to main content

Auth and sessions

Status: Scaffolded 2026-09-24

The browser never holds the API session token. It holds an httpOnly cookie that only the Next server can read, and the Next server turns that cookie into a Bearer header when it talks to the API. This page is the web half of auth and sessions.

vg_session: HttpOnly, Secure (off only on localhost in development), SameSite=Lax, Path=/, Max-Age equal to the API's SESSION_TTL_DAYS. Its value is the API session token, unchanged. No JWT, no claims, nothing to decode: the API looks up the hashed token on every request, so revoking a session in settings takes effect immediately everywhere.

src/app/api/session/route.ts​

The one route handler that touches the cookie. It proxies three API calls and never exposes the token to page JavaScript.

MethodBodyDoes
POST{ email, password }calls POST /api/auth/login, sets the cookie, returns { user }
POST{ magicToken }calls POST /api/auth/magic-link/verify, sets the cookie, returns { user }
DELETEcalls POST /api/auth/logout with the cookie's token, clears the cookie

Registration goes through the same handler with { email, password, handle }. The handler checks the Origin header against the site's own origin before doing anything, which with SameSite=Lax closes cross-site POSTs. The login form is a client component that posts JSON here and then router.push to next or /app/feed; the API's error envelope is passed through unchanged.

APINext route handlerBrowserAPINext route handlerBrowserPOST /api/session with email and passwordPOST /api/auth/login200 with token and userSet-Cookie vg_session (httpOnly) and user JSONGET /app/feed with cookieGET /api/feed with Authorization Bearer token200 itemsrendered page, no token in HTML

Server components​

src/lib/session.ts exports getSessionToken(), which reads cookies().get('vg_session') in a server component or route handler. src/lib/api.ts accepts an optional token and sets Authorization: Bearer; server components under /app pass it for the first render. The rendered HTML contains the data and never the token.

Client components under /app call src/lib/api.ts in the browser, where it sends no Bearer header and no cookie to the API (the API is a different origin). Interactive member calls therefore go through Next route handlers under /app/api/* that attach the token server-side, or directly to the API with a short-lived token minted by the session route. The scaffold implements the first pattern; the second is an open question for the messages socket.

middleware.ts​

Matches /app/:path*. If vg_session is absent, redirect to /login?next=<original path>. That is all it does. It does not validate the token, because the API does that on the first request the page makes and a 401 there clears the cookie and redirects again. Middleware is a convenience so a signed-out member never sees a half-rendered /app shell; it is not the authorization boundary. The API is.

Logout​

DELETE /api/session from the settings page or the header menu. The handler calls the API's logout so the session document is deleted, then clears the cookie with Max-Age=0. Other devices are unaffected; they are listed and revoked individually in settings through GET /api/me/sessions.

Why no JWT in localStorage​

A token readable by JavaScript is readable by any script on the page, including one injected through a dependency or a future XSS bug. An httpOnly cookie is invisible to scripts; the cost is a server hop per member request, and the site pays it. A JWT would also carry claims the API would have to trust without a lookup. Here the role is read from the database per request.