PART 3 - Logout, CORS & CSRF
In this article, we complete our session-based authentication system by focusing on logout semantics, cross-origin configuration, and CSRF protection.
At this stage, we already have:
- Redis-backed sessions
- Passport-based authentication
- Session rotation
- Per-user session tracking
What remains is to secure the edges: how sessions are terminated, how browsers are allowed to send cookies, and how we prevent forged requests.
Logout
A robust authentication system must support two logout scenarios:
- Revoking the current session only
- Revoking all active sessions belonging to a user
Both are critical for real-world security.
⚠️ Note
These endpoints should be implemented as HTTP POST.
In this guide,GETis used only to simplify browser testing and avoid external client cookie setup.
Logout (current session)
File: auth/auth.controller.ts
This endpoint revokes only the current session, leaving other active sessions (other devices or browsers) untouched.
@Get('logout')
@UseGuards(SessionAuthenticationGuard)
async logout(
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
) {
const userId = req.user.id;
const sessionId = req.sessionID;
// 1️⃣ Revoke current session in Redis
await this.sessionService.unregisterSession(userId, sessionId);
// 2️⃣ Passport cleanup
await new Promise<void>((resolve, reject) =>
req.logout((err) => (err ? reject(err) : resolve())),
);
// 3️⃣ Destroy HTTP session
await new Promise<void>((resolve, reject) =>
req.session.destroy((err) => (err ? reject(err) : resolve())),
);
// 4️⃣ Clear session cookie
res.clearCookie('sid', {
httpOnly: true,
secure: envs.NODE_ENV === 'production',
sameSite: 'lax',
});
return { success: true };
}
Execution flow:
- The session ID is removed from Redis and from the per-user session set
- Passport clears the authentication state
- The HTTP session is destroyed
- The browser cookie is removed
At this point, the user is logged out only from the current device.
Logout all sessions
File: auth/auth.controller.ts
This endpoint revokes every active session belonging to the user, across all devices.
@Get('logout-all')
@UseGuards(SessionAuthenticationGuard)
async logoutAll(
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
) {
const userId = req.user.id;
// 1️⃣ Revoke all sessions (including current one)
await this.sessionService.revokeAllSessions(userId);
// 2️⃣ Passport cleanup
await new Promise<void>((resolve, reject) =>
req.logout((err) => (err ? reject(err) : resolve())),
);
// 3️⃣ Destroy current session
await new Promise<void>((resolve, reject) =>
req.session.destroy((err) => (err ? reject(err) : resolve())),
);
// 4️⃣ Clear cookie
res.clearCookie('sid', {
httpOnly: true,
secure: envs.NODE_ENV === 'production',
sameSite: 'lax',
});
return { success: true };
}
This is typically used for security actions, such as:
- Password changes
- Account compromise recovery
- Manual “log out from all devices”
Typed authenticated request
File: src/types/request.types.ts
import type { Request } from "express";
export type AuthenticatedRequest = Request & {
user: { id: string };
};
Why is this important?
Passport dynamically injects req.user at runtime, but TypeScript has no knowledge of that by default.
This custom type:
- Prevents unsafe
anyaccess - Makes authentication assumptions explicit
- Improves DX and refactor safety
- Ensures
req.user.idis always available in protected routes
CORS Configuration
Browsers enforce strict rules around cross-origin requests.
If your frontend and backend live on different origins, your backend must explicitly allow credentials.
Otherwise:
- Cookies will not be sent
- Sessions will never load
- Authentication will silently fail
CORS setup
File: main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ["http://localhost:3000", "https://app.yourdomain.com"],
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "X-CSRF-Token", "Authorization"],
exposedHeaders: ["Set-Cookie"],
});
// ... remaining setup
}
Key points:
-
credentials: trueis mandatory for cookies - Origins must be explicitly whitelisted
- CSRF token headers must be allowed
CSRF Protection
CSRF is an attack where a malicious website tricks a user’s browser into making a request to your application while the user is already authenticated.
The key idea is:
- Browsers automatically send cookies (including session cookies)
- An attacker cannot read responses, but can force a request to be sent
So the server sees:
“Valid session cookie → must be a legitimate user”
…but the user never intended to perform that action.
More details: https://owasp.org/www-community/attacks/csrf
How CSRF is mitigated
CSRF attacks are prevented by requiring a secret, per-session token on every state-changing request.
Our approach:
-
Issue a CSRF token
- Only after authentication
- Bound to the current session
- Exposed via
GET /auth/csrf-token
-
Store the secret server-side
-
csurfstores it inside the session - The client never sees the secret
-
-
Validate on every write
-
POST,PUT,DELETE - Missing or invalid token → 403 Forbidden
-
A cookie alone is no longer sufficient.
The attacker cannot read or guess the CSRF token due to the Same-Origin Policy.
How does it work in real world?
For protected requests (e.g POST / PUT / DELETE):
- Frontend sends the token:
- Header:
X-CSRF-Token - or body field
_csrf
- Header:
-
csurfvalidates:- Is the session valid?
- Does the token match this session?
If not → 403 Forbidden
IMPORTANT: Please note than by implementing CSRF, when you rotate a session, the CSRF secret changes, therefore:
- Previously issued CSRF tokens become invalid
- Frontend must fetch a new CSRF token
This is expected and correct behavior.
Security tradeoff:
- ✔️ smaller attack window
- ❌ more frontend coordination
CSRF middleware
Add the following config to main.ts to set up csurf middleware:
pnpm i csurf
pnpm i -D @types/csurf
File: main.ts
import csurf from "csurf";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use((req: Request, res: Response, next: NextFunction) => {
const csrfExcludedPaths = ["/auth/google", "/auth/google/callback"];
if (csrfExcludedPaths.includes(req.path)) {
return next();
}
return csurf({ cookie: false })(req, res, next);
});
await app.listen(envs.PORT ?? 3000);
}
Why this configuration?
- CSRF secrets are stored inside the session, not cookies
- OAuth routes cannot include CSRF tokens
- Middleware runs after session & passport restoration
CSRF token endpoint
File: auth/auth.controller.ts
@Get('csrf-token')
@UseGuards(SessionAuthenticationGuard)
getCsrf(@Req() req: Request) {
return { csrfToken: req.csrfToken() };
}
This endpoint allows the frontend to:
- Fetch a valid CSRF token
- Bind it to the current authenticated session
- Refresh it after session rotation
⚠️ Important
When a session is rotated, the CSRF secret changes.
Previously issued tokens become invalid — this is expected and correct.
Example request sequence with CSRF
1️⃣ User logs in
GET /auth/google
→ Redirect to Google
GET /auth/google/callback
→ Session is regenerated
→ Session stored in Redis
→ Cookie set: sid=abc123
2️⃣ Client asks for CSRF token
GET /auth/csrf-token
Cookie: sid=abc123
Server:
- Loads session from Redis
- Generates CSRF token
- Returns it
{ "csrfToken":"XyZ123..." }
3️⃣ Client performs a protected action
POST /transfer
Cookie: sid=abc123
Header: X-CSRF-Token: XyZ123...
Body: { amount: 100 }
Server:
-
express-sessionloads session -
passport.sessionrestoresreq.user -
csurfvalidates token against session secret - Request is allowed ✅
4️⃣ Attack attempt (CSRF)
POST /transfer
Cookie: sid=abc123 (browser auto-sends it)
But:
- Attacker cannot read or generate the CSRF token
- Token missing or invalid → 403 Forbidden
Conclusion & Further Steps
We now have a production-ready, session-based authentication system with strong security guarantees:
- Redis-backed, revocable sessions
- Per-user session tracking
- Session rotation against fixation attacks
- Secure logout semantics
- Proper CORS configuration
- CSRF protection bound to sessions
- Clean Passport lifecycle integration
Possible extensions
- Session metadata (IP, device, timestamps)
- User-facing session management UI
- Security signal detection (IP / country changes)
- Absolute session lifetimes
- Automatic revocation on password or role changes
- Audit logging
- Rate limiting and abuse protection
⭐ Github Repo
If this repository saved you time or effort, please ⭐ star it on GitHub.
Top comments (0)