nextjs-cms

Authentication

JWT access and refresh tokens in httpOnly cookies, CSRF protection, and the client session hooks.

Code: packages/nextjs-cms/src/auth/

FileRole
index.tsauth(): the server-side session, read from the access_token cookie, cached per request with React cache()
jwt.tsSign and verify access/refresh tokens (ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, expirations from env)
lib/actions.tslogin, authRefresh, deleteSession: database-backed token lifecycle
csrf.tscreateCSRFToken, validateCSRFToken (HMAC with CSRF_TOKEN_SECRET)
react.tsxClient: SessionProvider, useSession, login, logout, getSession, getCsrfToken
hooks/useRefreshToken.tsxClient 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_token
  • auth() 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 in access_tokens joined with admins. Deleting the row (logout, admin removal) revokes the session at the next refresh.
  • Logout (deleteSession) deletes the admin's access_tokens rows 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).