Secure Authentication in Angular + NestJS Using HttpOnly JWT Cookies (Enterprise Pattern)
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:
localStoragesessionStorageIn-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
SameSiteWork 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
SameSiteor CSRF tokensShort-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: trueallows cookies to be sentOrigin 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
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
5. Auth Controller (Set HttpOnly Cookie)
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' };
}
Cookie Security Explained
| Option | Purpose |
httpOnly | Prevent JS access |
secure | HTTPS-only |
sameSite | CSRF mitigation |
maxAge | Enforced expiry |
6. Logout (Invalidate Cookie)
@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.
2. Global HTTP Interceptor (Optional but Recommended)
@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
| 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 minutesrefresh_token– 7–30 days (HttpOnly)
Flow
Access token expires → API returns
401Angular calls
/auth/refreshBackend validates refresh token
New access token is issued via cookie
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