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
Dependencies
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'). AnhttpOnlycookie is invisible to JavaScript — only the browser can send it with requests. Combined withSecure(HTTPS only) andSameSite=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.tsjticlaim, include it in both access token and refresh token payloadsSET refresh:${jti} ${userId} EX ${7 * 24 * 3600}(7-day TTL)refresh:${jti}from Redis (revoke refresh token); add access token's JTI to blacklist:SET blacklist:${jti} 1 EX ${remainingTTL}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.tshttpOnly; Secure; SameSite=Strict; Path=/auth/refreshcookie in the response instead of returning it in the JSON bodyres.clearCookie)req.cookies['refresh_token']) instead of request bodybackend/src/auth/jwt.strategy.ts(or guard)jtiexists in Redis blacklist (GET blacklist:${jti})UnauthorizedExceptionbackend/src/auth/dto/login-response.dto.tsrefresh_tokenfrom the response body — it is now cookie-onlybackend/src/redis/redis.service.ts(existing)set(key, value, ttlSeconds),get(key),del(key),exists(key)— thin wrappers over the existing Redis clientbackend/src/main.tscookie-parsermiddleware:app.use(cookieParser())pnpm add cookie-parser+pnpm add -D @types/cookie-parserMigration:
backend/src/migrations/refresh_tokenstable can be deprecated (tokens now live in Redis). Add a migration that drops it — but only after confirming no other code references it.New environment variables (
.env.production.example)Tests
AuthService: verify JTI is generated and stored in Redis on login; verify blacklist entry created on logoutDefinition of Done
httpOnly; Secure; SameSite=Strictcookiecookie-parsermiddleware registered inmain.tspnpm testandpnpm test:e2epassDependencies