EngineeringUnifying JWT, Cookie, and Frontend Login Expiry Policies
· AlgoSu
- #session
- #jwt
- #cookie
- #debugging
- #nestjs
"The Session Is Too Short"
A piece of user feedback came in:
"It's annoying to have to log in again every time I switch to something else and come back."
The login token (JWT) was designed to live for 2 hours. On top of that, the intended behavior was a sliding session: while the user is active, the server keeps issuing a fresh token, pushing the expiry further out. Two hours should be plenty, so why did sessions keep dropping?
Digging in, it turned out the production server's env var still held the initial value of 7 days, not the designed 2 hours. The token lifetime was, if anything, too long, and sessions still dropped after an hour.
Long story short: four layers were each running on a different clock. Twelve AI agents were each working on separate layers, so this kind of mismatch was exactly the bug that slips through unless someone looks at the whole picture at once.
Problem
The JWT lifetime (TTL) was designed as 2 hours, and production still held the initial value of 7 days, yet users were logged out after 1. Four layers (server env var, cookie, sliding refresh, frontend timer) each kept their own clock.
Decision
Unify the four scattered clocks into one source (the server env vars). This was a structural change via a SessionPolicyModule, not just swapping constants.
Result
The places that define session lifetime went from four to one. Changing one env var now carries through to the cookie, the refresh, and the frontend; 24 tests were added.
Four Clocks, Four Truths
Here's what we found:
7days
JWT TTL
Production env var (designed: 2h)
1hour
Cookie maxAge
Hardcoded constant
5min
Sliding threshold
Only refreshes within 5 min of expiry
65min
Frontend timer
Forces logout
Whichever one you fixed, another layer would still end the session. Let's go through them one by one.
Layer 1: A Leftover Value in the Production Env
Token lifetime is set by an environment variable, JWT_EXPIRES_IN. In production, that value lived in a Sealed Secret, a tool that encrypts Kubernetes secrets so they can be stored safely in Git.
// Value encrypted in aether-gitops' Sealed Secret
JWT_EXPIRES_IN=7d // Designed value is 2h — so why?The initial setting, 7d, was still sitting there. Token lifetime isn't a secret, yet it was locked in a secret store. Changing it meant running the encryption tool (kubeseal) and restoring the original plaintext .env. Changing one value was far harder than it needed to be.
Layer 2: Hardcoded Cookie Lifetime (maxAge)
The token travels in a browser cookie, and the cookie's lifetime was a number hardcoded in the code.
// services/gateway/src/auth/cookie.util.ts (before)
const COOKIE_MAX_AGE_SECONDS = 60 * 60; // Fixed at 1 hourNo matter how long the JWT lived, the browser threw the cookie away exactly 1 hour later. The JWT and the cookie each defined "session lifetime" separately, in two different places. Whether the JWT lifetime was set to 2h or 7d, sessions dropped after an hour, and this was why.
Layer 3: Sliding Refresh Was Effectively Off
The code that issues a fresh token on each request (an interceptor in the Gateway, the service that receives every request) has a threshold: how close to expiry a token must be before it gets refreshed. That threshold was 5 minutes.
// services/gateway/src/auth/token-refresh.interceptor.ts (before)
const REFRESH_THRESHOLD_SECONDS = 5 * 60; // Only refreshes within 5 min of expiryWith a 2-hour token, no request triggers a refresh for the first 1 hour 55 minutes. Since refreshes almost never happened, the session ended up hitting the 1-hour cookie (Layer 2) or the 65-minute frontend timer (Layer 4) first. "The session stays alive while you're active" simply wasn't happening.
Layer 4: The Frontend Ending the Session Before the Server
// frontend/src/hooks/useSessionKeepAlive.ts (before)
const SESSION_TIMEOUT_MS = 65 * 60 * 1000; // 65 minutesThe JWT lasts 2 hours, but the frontend forces a logout at 65 minutes. The server still considers the session valid; the client cuts it off first.
The Core Bug: Four Sources
The problem wasn't any single value. It was the structure.
Before: 4 independent clocks
Four places each managed the same thing ("session lifetime") on their own. Change one env var, and the other three don't follow. The principle of a single source of truth (SSoT), where a value is defined in exactly one place, was broken. That was the root of this bug.
The Fix: One Clock
The first plan was simply to replace each hardcoded value with the right one. But the lead agent pushed back:
"Don't hardcode session values; modularize them."
Fair point. Swapping numbers would mean hunting down all four places again the next time the policy changed. We needed a root-cause fix.
SessionPolicyModule: A Single Source of Truth
We designed it so that changing one server environment variable automatically carries through to both server and client.
After: Server env as the only source
The key: five environment variables decide everything.
| Env Var | Value | Description |
|---|---|---|
| JWT_EXPIRES_IN | 2h | Access token lifetime |
| SESSION_REFRESH_THRESHOLD | 1h | Sliding refresh threshold |
| SESSION_HEARTBEAT_INTERVAL | 10m | How often the frontend checks the session (heartbeat) |
| SESSION_TIMEOUT_BUFFER | 5m | Grace time added to the token lifetime (frontend session timeout = lifetime + buffer) |
| JWT_DEMO_EXPIRES_IN | 2h | Token lifetime for the trial demo account only |
Data Flow
- Server env vars: JWT_EXPIRES_IN=2h and 4 others
- SessionPolicyService: parses them into milliseconds and holds them in one place
- Injected into server-side users: JwtModule · Interceptor · OAuthService (social login)
- Public API: GET /auth/session-policy
- Frontend fetch: one call at app start, falling back to DEFAULT values on failure
Fixes by Layer
Layer 1 Fix: Override with Deployment env
Instead of re-encrypting the Sealed Secret, we used a Kubernetes rule: variables written directly in the server's run config (the Deployment's env: block) take precedence over ones loaded in bulk from a Secret (envFrom:). Below is the Gateway config in the deployment-config repository (aether-gitops).
# aether-gitops: algosu/base/gateway.yaml
env:
- name: JWT_EXPIRES_IN
value: "2h"
- name: SESSION_REFRESH_THRESHOLD
value: "1h"
- name: SESSION_HEARTBEAT_INTERVAL
value: "10m"
- name: SESSION_TIMEOUT_BUFFER
value: "5m"Token lifetime is not a secret, so there's no reason to lock it in a secret store. Keeping it as plain text in the manifest is the structurally sound choice.
Layer 2 Fix: Use the Token's Own Expiry
Instead of a constant, the cookie lifetime is now calculated from the expiry time written inside the JWT itself (its exp claim).
// services/gateway/src/auth/cookie.util.ts (after)
const decoded = jwt.decode(token) as { exp?: number } | null;
if (decoded?.exp) {
const remainingMs = decoded.exp * 1000 - Date.now();
if (remainingMs > 0) {
maxAge = Math.floor(remainingMs / 1000);
}
}Now when the JWT lifetime changes, the cookie lifetime follows automatically. Each time sliding refresh issues a new token, the cookie is renewed with it. If the token can't be decoded, it safely falls back to 1 hour and writes a structured log.
Layer 3 Fix: Sliding Threshold from 5 min → 1 hour
// services/gateway/src/auth/token-refresh.interceptor.ts (after)
constructor(
private readonly sessionPolicy: SessionPolicyService,
// ...
) {}
// Injected from policy service instead of hardcoded constant
const thresholdMs = this.sessionPolicy.getRefreshThresholdMs();Now, once half (1 hour) of the 2-hour lifetime has passed, every request refreshes the token. Active users effectively never get cut off.
"Refresh on every request" was rejected: re-signing the JWT every time costs CPU, and every response would carry a Set-Cookie header. The 50% threshold is the compromise between performance and user experience.
Layer 4 Fix: Fetch the Policy from the Server
The defaults below are used only when the policy can't be fetched from the server.
// frontend/src/lib/session-policy.ts
export const DEFAULT_SESSION_POLICY: ClientSessionPolicy = {
accessTokenTtlMs: 60 * 60 * 1000, // 1h (shorter than server's 2h)
heartbeatIntervalMs: 10 * 60 * 1000, // 10m
sessionTimeoutMs: 65 * 60 * 1000, // 1h + 5m
refreshThresholdMs: 30 * 60 * 1000, // 30m
};
export async function fetchSessionPolicy(): Promise<ClientSessionPolicy> {
const res = await fetch('/api/auth/session-policy');
// Falls back to DEFAULT_SESSION_POLICY on failure
}The frontend calls GET /auth/session-policy once when the app starts. No more hardcoded values. When the server env changes, the frontend picks up the new policy on its next start.
The default session timeout of 65 minutes is the 1-hour token lifetime plus a 5-minute buffer (1h + 5m). This DEFAULT fallback is deliberately shorter than the server default (2h). If the fetch fails, the client expires the session early rather than hitting a surprise authentication failure (401). It is a safety net.
Before / After
| Item | After | Change |
|---|---|---|
| JWT TTL | 2h | 7d → 2h |
| Cookie maxAge | Dynamic | Fixed 1h → synced with JWT exp |
| Sliding threshold | 1h | 5 min → 1 hour (50% of TTL) |
| Frontend timer | Policy fetch | Fixed 65 min → synced with server policy |
| Item | Before | After |
|---|---|---|
| Places defining the value | 4 (each hardcoded) | 1 (server env) |
| On env change | Manual sync across 4 locations | Automatic propagation |
| New external dependencies | None | None (self-contained duration parser) |
| Tests added | None | Gateway 8 + Frontend 16 |
Custom Duration Parser
SessionPolicyService has a built-in parser that turns strings like 2h, 30m, or 500ms into milliseconds. There's a reason we didn't depend directly on the ms package, which does the same job.
ms is currently a transitive dependency, something another package pulled in. It's usable only because the package manager hoisted it to the top level. Relying on that means the parser could vanish the next time the package manager reshuffles the dependency tree. So we built the parser in and removed the dependency altogether. That way, the fail-fast behavior (problems surfacing right away) is something we guarantee ourselves. Supported formats are Nd, Nh, Nm, Ns, Nms, and plain numbers (treated as seconds).
Lesson Checklist
Here's what this bug taught, as a checklist.
Structure, Not Constants
The first plan for this bug was "replace the 4 hardcoded numbers with the right numbers." That would have worked for now.
But the next time the session policy changes? You'd hunt down all four places again, and someone would miss one. Replacing constants fixes a bug; changing the structure prevents the next one.
Introducing SessionPolicyModule touched 19 files and added 24 tests. Not a small job, but now changing the session policy means editing one env line in the Deployment yaml. Server, client, and cookies all follow.
Don't keep a constant that "happens to mean the same thing" next to a policy value controlled by an env var. If they mean the same thing, they must read from the same source. If changing one env var doesn't bring all related state along, that spot is the seed of your next bug.