Authentication
JWT access and refresh tokens in httpOnly cookies, CSRF protection, and the client session hooks.
Code: packages/nextjs-cms/src/auth/
| File | Role |
|---|---|
index.ts | auth(): the server-side session, read from the access_token cookie, cached per request with React cache() |
jwt.ts | Sign and verify access/refresh tokens (ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, expirations from env) |
lib/actions.ts | login, authRefresh, deleteSession: database-backed token lifecycle |
csrf.ts | createCSRFToken, validateCSRFToken (HMAC with CSRF_TOKEN_SECRET) |
react.tsx | Client: SessionProvider, useSession, login, logout, getSession, getCsrfToken |
hooks/useRefreshToken.tsx | Client hook that refreshes the access token |
Token model
login(username, password)
├─ bcrypt.compare with admins.password
├─ sign access token { id, sub: username, language } short-lived (ACCESS_TOKEN_EXPIRATION)
├─ sign refresh token long-lived (REFRESH_TOKEN_EXPIRATION)
├─ INSERT access_tokens (admin_id, access_token, refresh_token, expiration)
└─ set httpOnly cookies: access_token, refresh_tokenauth()only verifies the access token's signature and expiry. No database hit per request.- Refresh (
/api/auth/refresh→authRefresh) verifies the refresh token and looks it up inaccess_tokensjoined withadmins. Deleting the row (logout, admin removal) revokes the session at the next refresh. - Logout (
deleteSession) deletes the admin'saccess_tokensrows and clears the cookies.
The admin's API client retries a request once after a 401 by calling the refresh endpoint, so short access-token lifetimes are invisible to editors.
CSRF
proxy.ts in the admin app validates every POST, PUT, PATCH, and DELETE request: the csrf_token cookie must match the x-csrf-token header (double-submit, signed with CSRF_TOKEN_SECRET). The client fetches a token from /api/auth/csrf and attaches it to mutating requests.
Session on the client
SessionProvider holds the session in context; useSession({ required: true }) redirects to login when there's none. The admin layout uses it to guard every authenticated page, and the server re-checks with auth() in every route handler and procedure, so the client check is only for UX.
Language
The admin's UI language is stored on the admin (admins.language) and embedded in the access token (language), so server code can translate errors without a database lookup: getString(key, session.user.language). accountSettings.updateLanguage validates the language against i18n.supportedLanguages and stores it; the token picks it up on its next refresh.
Tests
auth/jwt.test.ts and auth/csrf.test.ts are unit tests; auth/__tests__/integration runs the login/refresh/logout lifecycle against the test database. They need ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET in the environment (see INTEGRATION_TESTS.md).