Nest Authbeta

Sessions & Tokens

How Nest Auth issues, refreshes, and validates JWT access and refresh tokens.

Every authenticated request in Nest Auth is backed by two things: a session (a row in your session store) and a token pair (an access token and a refresh token). Understanding how they interact saves a lot of debugging.

The token pair

TokenLifetimePurpose
Access tokenShort (default 15m)Sent on every request to prove identity. Stateless JWT — the server validates it without a DB hit.
Refresh tokenLong (default 7d)Sent only to /auth/refresh-token to mint a new access token. Bound to a session row.

Both are signed JWTs. Both lifetimes are configurable on session.accessTokenValidity and session.refreshTokenValidity (any ms string works: '15m', '2h', '30d').

Set session.accessTokenType to one of:

  • 'header' — server returns tokens in the JSON body. Client stores them and sends Authorization: Bearer <token> on every request.
  • 'cookie' — server sets HttpOnly, Secure, SameSite cookies. Client doesn't see the tokens. Required for full XSS protection.
  • null (default) — auto-detect. The server reads the x-access-token-type header from the client and decides per request. The JS client sends this header automatically based on its own config. This lets one backend serve both a web app (cookies) and a mobile app (Bearer) without separate endpoints.

CORS gotcha: in the auto-detect path you must include x-access-token-type in your CORS allowedHeaders, or the browser preflight fails. See CORS & Security.

Auto-refresh

When the JS client gets a 401 from any endpoint, it transparently:

  1. Calls /auth/refresh-token once.
  2. Updates its in-memory tokens.
  3. Retries the original request with the new access token.

This is gated by two safeties so it can't go wrong:

  • RefreshQueue — if a hundred parallel requests all 401 at once, only one refresh fires. The other 99 await the same promise.
  • RetryTracker — every original request is retried at most once. If the retry also fails, the user is logged out and the original 401 surfaces to the caller.

Both are exported from @ackplus/nest-auth-client if you need to instrument or replace them.

Refresh-token rotation and reuse

Every refresh issues a new refresh token, and the session stores a hash of the current one. Presenting a token that has already been replaced is treated as reuse, a sign the token was stolen: the server answers 401 REFRESH_TOKEN_INVALID, emits refresh_token_reuse_detected, and by default revokes the session (session.refreshTokenReuse.revokeSession, default true).

That has one cost on unreliable networks. If a refresh's reply is lost (dropped mobile signal, a client timeout, a proxy cutting the connection, the server stopped mid-response), the client still holds the old token and its retry looks exactly like reuse, so the user is signed out. graceSeconds accepts that retry:

session: {
  refreshTokenReuse: {
    revokeSession: true, // unchanged
    graceSeconds: 30,    // default 0 = off
  },
}
  • Within graceSeconds of a rotation, the token that rotation replaced is accepted once. The server rotates again, answers with a fresh pair, and emits refresh_token_grace_used instead of the reuse event.
  • After that retry, neither the retried token nor the pair whose reply was lost works. A second use is reuse.
  • Outside the window, or for any other token, nothing changes.

The trade-off: whoever holds the previous token can use it once inside the window, the same window the real client needs for its retry. Leave the default of 0 if you don't want that. The previous hash lives in the session's existing refreshToken column, so there's no migration and custom session stores need no change. Downgrading below 2.18.0 after sessions have rotated with the grace on signs those sessions out once.

Sliding expiration

Set session.slidingExpiration: true and a session's expiry rolls forward on every successful refresh. A user who is active daily never gets logged out; a user who walks away for refreshTokenValidity loses their session.

The trade-off is write amplification — every refresh is a session-row update. For Redis-backed sessions this is negligible; for SQL it's worth measuring on high-traffic tenants. See Scaling.

"Remember me"

Pass rememberMe: false on POST /auth/login to make a session non-persistent — useful for a shared-device "uncheck remember me" flow.

  • Cookie mode: the auth cookies are written as session cookies (no Max-Age), so they're cleared when the browser closes. The choice is sticky across token refresh (via a session-scoped marker cookie), so the first refresh doesn't silently upgrade them to persistent. Omitting rememberMe (or true) keeps the normal persistent cookies.
  • Header mode: the backend doesn't manage storage — the client decides persistence by its storage adapter. Construct the AuthClient with SessionStorageAdapter (cleared on tab close) instead of the default LocalStorageAdapter when the box is unchecked.

Password-hash prefix in the JWT

Every access token Nest Auth issues includes a short prefix of the user's password hash as a claim. On every authenticated request the guard compares that prefix against the current user record. If the user resets their password (or an admin does it on their behalf), every outstanding access token across every device becomes invalid the next time it hits a guarded endpoint — without any explicit revocation step or session sweep.

This is how "log out all devices on password change" works without scanning the session store.

maxSessionsPerUser

Caps how many concurrent sessions one user can hold (default 10). When a new login pushes the count over the limit, the least-recently-active session is evicted. Set a low number like 3 for high-security flows.

session: { maxSessionsPerUser: 3 }

A different cap per account

Pass a function instead of a number to decide the cap per sign-in. It receives the account id and the session data about to be stored — which carries that account's roles and permissions — and may be async:

session: {
  maxSessionsPerUser: ({ userId, data }) =>
    data.roles?.some((r) => r.name === 'staff') ? 20 : 3,
},
// Async is fine — look the plan up wherever it lives.
session: {
  maxSessionsPerUser: async ({ userId }) => {
    const plan = await billing.planFor(userId);
    return plan === 'pro' ? 10 : 2;
  },
},

The cap must be a positive whole number. Anything else — unset, 0, negative, fractional, NaN, or a non-number returned by the function — falls back to the default of 10. There is no way to disable the cap; set it high if you do not want one.

Evictions fire onRevoked

An evicted session goes through the normal revoke path, so session.onRevoked fires with reason 'evicted'. Use it to audit or clean up:

session: {
  onRevoked: (session, reason) => {
    if (reason === 'evicted') audit.log('session evicted', session.userId);
  },
},

Where to look in the code

  • JwtService — signs / verifies tokens. Customize signing via session.jwt.secret.
  • SessionManagerService — creates, refreshes, revokes session rows.
  • TokenManager (in @ackplus/nest-auth-client) — abstracts header vs cookie mode on the client.
  • RefreshQueue, RetryTracker — exported from @ackplus/nest-auth-client.

See also

On this page