Skip to content

Tech Story: Redis-backed refresh tokens, JTI blacklist, and httpOnly cookies #109

@GitAddRemote

Description

@GitAddRemote

Tech Story

As a security engineer, I want Station's authentication layer to store refresh tokens in Redis with a JTI blacklist and deliver them to browsers via httpOnly cookies so that tokens can be instantly revoked, are protected from JavaScript-based theft (XSS), and meet the security standards of production web applications.

ELI5 Context

What is a refresh token and why does it need better storage?
When you log in, you get two tokens: a short-lived access token (15 minutes) and a longer-lived refresh token (7 days). The refresh token is used to get a new access token when the old one expires. Currently refresh tokens are stored in the database — fine for correctness, but slow if you need to check or revoke them frequently. Redis is an in-memory store (like a lightning-fast dictionary), so lookups take microseconds instead of milliseconds.

What is a JTI blacklist and why does it matter?
JTI stands for "JWT ID" — a unique identifier baked into every token. When a user logs out, their access token is still technically valid for up to 15 minutes (until it naturally expires). Without a blacklist, a stolen token can be used for up to 15 minutes after logout. The blacklist is a Redis set of "cancelled" JTIs. Every protected request checks: "is this token's JTI in the blacklist?" If yes, reject it immediately. The blacklist entry auto-expires when the token would have expired anyway — no cleanup needed.

What is an httpOnly cookie and why is it more secure than localStorage?
Many apps store tokens in localStorage (browser storage readable by JavaScript). The problem: if an attacker injects malicious JavaScript into your page (XSS attack), they can steal the token with one line: localStorage.getItem('token'). An httpOnly cookie is invisible to JavaScript — only the browser can send it with requests. Combined with Secure (HTTPS only) and SameSite=Strict (only sent to your domain), it's the industry-standard secure delivery mechanism for browser tokens.

Does this change anything for the user? No. Login, logout, and token refresh work the same from the user's perspective. The security improvements are invisible.

Technical Elaboration

File changes

backend/src/auth/auth.service.ts

  • On login: generate a UUID v4 as the jti claim, include it in both access token and refresh token payloads
  • Store refresh token metadata in Redis: SET refresh:${jti} ${userId} EX ${7 * 24 * 3600} (7-day TTL)
  • On logout: delete refresh:${jti} from Redis (revoke refresh token); add access token's JTI to blacklist: SET blacklist:${jti} 1 EX ${remainingTTL}
  • On refresh: look up refresh:${jti} in Redis; if missing (expired or revoked) → 401. On success, delete old entry and create new refresh token (rotation).

backend/src/auth/auth.controller.ts

  • Login endpoint: set the refresh token as an httpOnly; Secure; SameSite=Strict; Path=/auth/refresh cookie in the response instead of returning it in the JSON body
  • Logout endpoint: clear the cookie (res.clearCookie)
  • Refresh endpoint: read refresh token from cookie (req.cookies['refresh_token']) instead of request body

backend/src/auth/jwt.strategy.ts (or guard)

  • Before passing the request through: check if the access token's jti exists in Redis blacklist (GET blacklist:${jti})
  • If found → throw UnauthorizedException

backend/src/auth/dto/login-response.dto.ts

  • Remove refresh_token from the response body — it is now cookie-only

backend/src/redis/redis.service.ts (existing)

  • Add helper methods: set(key, value, ttlSeconds), get(key), del(key), exists(key) — thin wrappers over the existing Redis client

backend/src/main.ts

  • Add cookie-parser middleware: app.use(cookieParser())
  • Install: pnpm add cookie-parser + pnpm add -D @types/cookie-parser

Migration: backend/src/migrations/

  • The refresh_tokens table can be deprecated (tokens now live in Redis). Add a migration that drops it — but only after confirming no other code references it.
  • Alternatively: keep the table as a fallback audit log (record refresh token issuance events without storing the actual token). Document the decision.

New environment variables (.env.production.example)

REFRESH_TOKEN_TTL_SECONDS=604800   # 7 days
ACCESS_TOKEN_TTL_SECONDS=900       # 15 minutes

Tests

  • Unit tests for AuthService: verify JTI is generated and stored in Redis on login; verify blacklist entry created on logout
  • Unit test for JWT strategy: mock Redis returning a blacklist hit → expect 401
  • E2E test: login → logout → attempt to use the old access token → expect 401

Definition of Done

  • Refresh tokens stored in Redis with 7-day TTL (not only in DB)
  • JTI included in access token payload; blacklist checked on every authenticated request
  • Logout immediately invalidates the access token via JTI blacklist
  • Refresh token delivered as httpOnly; Secure; SameSite=Strict cookie
  • Refresh endpoint reads token from cookie, not request body
  • cookie-parser middleware registered in main.ts
  • Unit tests for Redis storage, blacklist check, and cookie delivery
  • E2E test: post-logout token use returns 401
  • No refresh tokens exposed in JSON response bodies
  • pnpm test and pnpm test:e2e pass

Dependencies

Metadata

Metadata

Assignees

Labels

backendBackend services and logicdatabaseSchema, migrations, indexingsecuritySecurity, auth, and permissionstech-storyTechnical implementation story

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions