DEV Community

Cover image for Building Secure Session-Based Authentication in NestJS - Part 3
juanpeyrot
juanpeyrot

Posted on

Building Secure Session-Based Authentication in NestJS - Part 3

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:

  1. Revoking the current session only
  2. 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, GET is 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 };
}
Enter fullscreen mode Exit fullscreen mode

Execution flow:

  1. The session ID is removed from Redis and from the per-user session set
  2. Passport clears the authentication state
  3. The HTTP session is destroyed
  4. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 any access
  • Makes authentication assumptions explicit
  • Improves DX and refactor safety
  • Ensures req.user.id is 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
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • credentials: true is 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:

  1. Issue a CSRF token
    • Only after authentication
    • Bound to the current session
    • Exposed via GET /auth/csrf-token
  2. Store the secret server-side
    • csurf stores it inside the session
    • The client never sees the secret
  3. 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
  • csurf validates:
    • 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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() };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2️⃣ Client asks for CSRF token

GET /auth/csrf-token
Cookie: sid=abc123
Enter fullscreen mode Exit fullscreen mode

Server:

  • Loads session from Redis
  • Generates CSRF token
  • Returns it
{ "csrfToken":"XyZ123..." }
Enter fullscreen mode Exit fullscreen mode

3️⃣ Client performs a protected action

POST /transfer
Cookie: sid=abc123
Header: X-CSRF-Token: XyZ123...
Body: { amount: 100 }
Enter fullscreen mode Exit fullscreen mode

Server:

  • express-session loads session
  • passport.session restores req.user
  • csurf validates token against session secret
  • Request is allowed ✅

4️⃣ Attack attempt (CSRF)

POST /transfer
Cookie: sid=abc123   (browser auto-sends it)
Enter fullscreen mode Exit fullscreen mode

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

  1. Session metadata (IP, device, timestamps)
  2. User-facing session management UI
  3. Security signal detection (IP / country changes)
  4. Absolute session lifetimes
  5. Automatic revocation on password or role changes
  6. Audit logging
  7. Rate limiting and abuse protection

⭐ Github Repo

If this repository saved you time or effort, please ⭐ star it on GitHub.

📚 Article Series

Top comments (0)