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

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

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

---

## Backend Implementation (NestJS)

---

## 1\. Required Packages

```bash
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`

```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
    

---

## 3\. JWT Strategy (Read Token from Cookie)

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

### `jwt.strategy.ts`

```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`

```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
    

---

## 5\. Auth Controller (Set HttpOnly Cookie)

### `auth.controller.ts`

```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' };
}
```

### Cookie Security Explained

| Option | Purpose |
| --- | --- |
| `httpOnly` | Prevent JS access |
| `secure` | HTTPS-only |
| `sameSite` | CSRF mitigation |
| `maxAge` | Enforced expiry |

---

## 6\. Logout (Invalidate Cookie)

```ts
@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`

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

### Usage

```ts
@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`

```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.

---

## 2\. Global HTTP Interceptor (Optional but Recommended)

```ts
@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

```ts
@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

| Area | Recommendation |
| --- | --- |
| XSS | HttpOnly cookies |
| CSRF | SameSite or CSRF token |
| Token lifetime | Short-lived access token |
| Refresh strategy | Separate refresh token |
| CORS | Explicit origin + credentials |
| Storage | Never localStorage |

---

## Optional: Refresh Token Strategy (Strongly Recommended)

### 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
