Skip to main content

Command Palette

Search for a command to run...

Secure Authentication in Angular + NestJS Using HttpOnly JWT Cookies (Enterprise Pattern)

Updated
5 min read
S

Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.

Authentication is one of the most common areas where frontend applications compromise security—often unintentionally. Storing JWTs in localStorage, exposing tokens to JavaScript, or relying on fragile refresh logic are still widespread anti-patterns.

In this article, we’ll design a production-grade authentication system using Angular and NestJS that:

  • Stores JWTs in HttpOnly cookies

  • Prevents token access from JavaScript (XSS-safe)

  • Supports stateless authentication

  • Is RBAC-ready

  • Works cleanly with Angular routing and guards

  • Scales for enterprise and multi-tenant systems

This pattern is widely used in financial, healthcare, and enterprise SaaS platforms.


Why HttpOnly Cookies for JWT?

The Core Problem

Most frontend apps store JWTs in:

  • localStorage

  • sessionStorage

  • In-memory variables

All three are vulnerable to XSS attacks.

The Correct Solution

Store JWTs in HttpOnly, Secure cookies, which:

  • Are not accessible via JavaScript

  • Are automatically sent with requests

  • Can be constrained via SameSite

  • Work naturally with browser security models

This shifts responsibility from frontend token handling to backend-driven session control.


Architecture Overview

Key Principles

  • JWTs stored in HttpOnly cookies

  • No token access from Angular code

  • Stateless backend authentication

  • CSRF protection via SameSite or CSRF tokens

  • Short-lived access tokens

  • Backend-validated session lifecycle

High-Level Flow

Angular Browser
  └── HttpOnly Cookie (JWT)
        └── NestJS Auth Guard
              └── Protected API

Backend Implementation (NestJS)


1. Required Packages

npm install @nestjs/jwt passport passport-jwt bcrypt cookie-parser

These provide:

  • JWT signing and validation

  • Passport strategy support

  • Secure password hashing

  • Cookie parsing middleware


2. App Bootstrap (Enable Cookies & CORS)

Cookies will not work unless CORS and credentials are configured correctly.

main.ts

import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use(cookieParser());

  app.enableCors({
    origin: ['https://your-frontend.com'],
    credentials: true
  });

  await app.listen(3000);
}

bootstrap();

Why This Matters

  • credentials: true allows cookies to be sent

  • Origin must be explicit, not *

  • Cookie parsing is required for Passport extraction


By default, Passport reads JWTs from headers. We override this to extract from cookies.

jwt.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req) => req?.cookies?.access_token
      ]),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET
    });
  }

  async validate(payload: any) {
    return {
      userId: payload.sub,
      email: payload.email,
      roles: payload.roles
    };
  }
}

Design Notes

  • Token is never exposed to Angular

  • Expired tokens are rejected automatically

  • User context is injected into req.user


4. Authentication Service (JWT Generation)

auth.service.ts

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private usersService: UsersService
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);

    if (!user || !(await bcrypt.compare(password, user.password))) {
      return null;
    }

    return user;
  }

  async login(user: any) {
    const payload = {
      sub: user.id,
      email: user.email,
      roles: user.roles
    };

    return {
      accessToken: this.jwtService.sign(payload, {
        expiresIn: '15m'
      })
    };
  }
}

Why Short-Lived Tokens?

  • Limits damage if compromised

  • Forces refresh rotation

  • Improves session control


auth.controller.ts

@Post('login')
async login(
  @Body() body,
  @Res({ passthrough: true }) res: Response
) {
  const user = await this.authService.validateUser(
    body.email,
    body.password
  );

  if (!user) {
    throw new UnauthorizedException('Invalid credentials');
  }

  const { accessToken } = await this.authService.login(user);

  res.cookie('access_token', accessToken, {
    httpOnly: true,
    secure: true,        // true in production
    sameSite: 'strict',  // or 'lax'
    maxAge: 15 * 60 * 1000
  });

  return { message: 'Login successful' };
}
OptionPurpose
httpOnlyPrevent JS access
secureHTTPS-only
sameSiteCSRF mitigation
maxAgeEnforced expiry

@Post('logout')
logout(@Res({ passthrough: true }) res: Response) {
  res.clearCookie('access_token');
  return { message: 'Logged out' };
}

Stateless systems invalidate sessions by removing the cookie.


7. Protecting Routes with Auth Guards

jwt-auth.guard.ts

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Usage

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req) {
  return req.user;
}

At this point:

  • Authentication is enforced

  • User identity is trusted

  • RBAC checks can be layered on top


Frontend Integration (Angular)


1. HttpClient Configuration

Cookies are not sent by default. withCredentials is mandatory.

auth.service.ts

@Injectable({ providedIn: 'root' })
export class AuthService {
  private api = 'https://api.yourdomain.com';

  constructor(private http: HttpClient) {}

  login(data: { email: string; password: string }) {
    return this.http.post(
      `${this.api}/auth/login`,
      data,
      { withCredentials: true }
    );
  }

  logout() {
    return this.http.post(
      `${this.api}/auth/logout`,
      {},
      { withCredentials: true }
    );
  }

  getProfile() {
    return this.http.get(
      `${this.api}/users/profile`,
      { withCredentials: true }
    );
  }
}

Angular never sees the token, yet authentication works seamlessly.


@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler) {
    return next.handle(
      req.clone({ withCredentials: true })
    );
  }
}

Benefits

  • Centralized cookie handling

  • Easier 401 handling

  • Cleaner service code


3. Angular Route Guard

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(
    private auth: AuthService,
    private router: Router
  ) {}

  async canActivate(): Promise<boolean> {
    try {
      await firstValueFrom(this.auth.getProfile());
      return true;
    } catch {
      this.router.navigate(['/login']);
      return false;
    }
  }
}

This ensures routes remain protected even on page reloads.


Security Best Practices Summary

AreaRecommendation
XSSHttpOnly cookies
CSRFSameSite or CSRF token
Token lifetimeShort-lived access token
Refresh strategySeparate refresh token
CORSExplicit origin + credentials
StorageNever localStorage

Cookies

  • access_token – 15 minutes

  • refresh_token – 7–30 days (HttpOnly)

Flow

  1. Access token expires → API returns 401

  2. Angular calls /auth/refresh

  3. Backend validates refresh token

  4. New access token is issued via cookie

  5. User session continues seamlessly


Final Thoughts

This architecture:

  • Eliminates frontend token exposure

  • Aligns with browser security primitives

  • Scales to RBAC and multi-tenant systems

  • Works cleanly with Angular’s routing model

  • Meets enterprise security expectations