All Docs
FeaturesMaking Tax DigitalUpdated March 11, 2026

Security Update: JWT Session Expiry & Invalidation (SEC-18)

Security Update: JWT Session Expiry & Invalidation (SEC-18)

Version: 1.0.410
Severity: High
Component: Authentication (src/lib/auth/index.ts)


Background

The platform uses Auth.js configured with strategy: 'jwt' — a stateless session approach where the session state lives entirely inside a signed JWT token stored in the user's browser. Stateless JWTs are convenient but carry an inherent trade-off: because no session record exists on the server, the server cannot unilaterally invalidate a token once it has been issued.

Prior to this release, two compounding issues existed:

  1. No explicit maxAge — Auth.js defaulted to a 30-day session lifetime, meaning a token issued on login would remain cryptographically valid for a month.
  2. No server-side revocation — When a user logged out, the JWT was deleted from the browser but nothing prevented a copy of that token (e.g. captured from a network log, browser extension, or XSS payload) from being replayed against the API for the remainder of its 30-day window.

For a platform that stores HMRC OAuth credentials and submits tax data on behalf of users, this exposure was unacceptable.


What Was Fixed

1. Explicit Session Lifetime (8 Hours)

The NextAuth configuration in src/lib/auth/index.ts now sets an explicit maxAge:

// src/lib/auth/index.ts
export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
    maxAge: 8 * 60 * 60, // 8 hours
  },
  // ...
};

8 hours is a standard session window for financial-grade web applications. It covers a full working day without requiring re-authentication, while ensuring that abandoned or stolen sessions expire within a predictable, short window.

2. JWT Revocation Blocklist via Upstash Redis

Upstash Redis was already provisioned for rate limiting. This release extends its use to hold a JWT revocation blocklist.

On logout, the token's jti (JWT ID — a unique identifier embedded in every JWT) is written to Redis with a TTL equal to the session maxAge:

// Pseudocode — actual implementation in src/lib/auth/index.ts
await redis.set(`revoked:${token.jti}`, '1', { ex: 8 * 60 * 60 });

On every authenticated request, the jwt callback checks whether the incoming token's jti is present in the blocklist:

jwt: async ({ token }) => {
  if (token.jti) {
    const revoked = await redis.get(`revoked:${token.jti}`);
    if (revoked) return null; // session rejected
  }
  return token;
},

A null return from the jwt callback causes Auth.js to treat the request as unauthenticated, redirecting the user to the login page.

Redis key TTL is set to match maxAge, so revoked entries are automatically cleaned up — the blocklist never accumulates stale data.


Security Posture: Before vs After

ScenarioBeforeAfter
User logs outJWT deleted from browser onlyJWT jti added to Redis blocklist
Attacker replays captured JWT after logout✅ Accepted (valid for up to 30 days)❌ Rejected immediately
Session lifetime30 days (framework default)8 hours (explicit)
Abandoned session windowUp to 30 daysUp to 8 hours
Server-side revocationNot possibleImmediate via Redis blocklist

What Is Not Yet Addressed

  • Session rotation after MFA / privilege escalation — sessions are not yet re-issued after a user completes MFA verification or is granted elevated privileges. A new jti should be issued at these points to invalidate the pre-escalation token. This is tracked as a follow-up.
  • Database sessions — switching Auth.js to strategy: 'database' would provide the strongest possible revocation guarantees (a deleted database row instantly invalidates a session, with no Redis dependency). This remains the recommended long-term direction given HMRC's security requirements, and is under consideration for a future release.

Impact on Users

  • Session duration changes from 30 days to 8 hours. Users will be required to log in again if their session is inactive for 8 hours.
  • Logout is now fully effective server-side. Clicking logout invalidates the session immediately — not just in the current browser tab.
  • No changes to the login flow, MFA prompts, or HMRC OAuth connection process.

For Developers

If you run the platform locally, ensure your .env includes the Upstash Redis connection variables (already required for rate limiting). No new environment variables are introduced by this change — the existing Redis client is reused.

The relevant file for this change is:

src/lib/auth/index.ts