It’s been a while since we last connected!
In this concise guide, I’ll demonstrate how straightforward Cookie Authentication is in Angular, broken down into just five steps.
From my perspective, Cookies are often the go-to solution for many apps. They keep the frontend streamlined and easy to follow, while shifting the heavy work to the server side. However, this approach doesn’t suit every case. Depending on your requirements, you could consider a combined strategy, a pure token-based method (such as JWTs), or even OAuth 2.0 paired with OIDC.
For those who want more depth, my brief e-book covers the topic thoroughly, including Angular code samples and mock servers for practical exercises. You’re welcome to give it a look!
AccademiaDev: text-focused web development training!
My goal is to share clear, actionable knowledge, cutting out the extra fluff and padding that often fills conventional books. Leveraging my background in consulting and teaching, these interactive, web-based courses combine prose, code examples, and assessments to offer practical know-how in a way that is both effective and enjoyable.
Before we begin: the server
For learning purposes, I've put together a demonstration back-end, which can be found at this link.
- Fetch the source (or clone the repository)
- Launch a terminal session
- Navigate into the directory using
cd - Install required packages via
npm install - Start the server with
npm run dev
When everything works, hitting http://localhost:3000 in your browser should display a response. That means we're ready to move on!
The back-end expects your front-end to run on
localhost:4200. If that's not where it runs, place an.envfile in the project folder and assign your front-end's full URL to theFRONTEND_URLvariable. This is needed to handle CORS.
Step 1: Getting the CSRF Token
A CSRF Token is a unique value stored by the server as a cookie in the browser, safeguarding against CSRF vulnerabilities (including cross-origin same-site requests and login CSRF). With every request that changes state, it must go back to the server as a header — Angular takes care of that automatically.
Right after the application initializes, we need to fetch the CSRF token from the server. The provideAppInitializer utility is what we'll employ for this task.
import { PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
// ...
bootstrapApplication(App, {
providers: [
provideAppInitializer(() => {
const http = inject(HttpClient);
const platformId = inject(PLATFORM_ID);
// Skip on the server (when using SSR), otherwise the request will be
// cached and not repeated on the client-side, thus not grabbing the token.
if (isPlatformServer(platformId)) return Promise.resolve();
return firstValueFrom(http.get(`${env.apiUrl}/csrf-token`)
}),
provideHttpClient(),
],
});
The automatic behavior of Angular handles this token for you: it stores the cookie when it shares the name XSRF-TOKEN, and includes it as the X-XSRF-TOKEN header on each outgoing request.
If your backend relies on alternative names, a custom configuration lets you replace those defaults:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'YOUR-COOKIE-NAME',
headerName: 'YOUR-HEADER-NAME',
}),
),
]
};
Warning: Angular does not send this to absolute URLs. Because of that, when you're developing on
localhost, my tip is to use something like//localhost:3000instead. This gets around Angular's restrictions, probably as a result of a bug, but it's handy for development.
Step 2: Sending the Cookie in localhost
What's great about cookies is that the browser includes them in requests automatically—but only to the very server that set them, and that works solely when the domain matches. Yet, while you're building the app, your backend runs on a separate "domain" because the port number differs. To handle that, we'll set up a basic interceptor that sends them along on its own:
export const apiInterceptor: HttpInterceptorFn = (
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
// Include the `apiUrl` in your environment, like `//localhost:3000`
if (req.url.includes(environment.apiUrl)) {
req = req.clone({ withCredentials: true });
}
return next(req);
}
And provide it:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([apiInterceptor])
),
// ...
]
};
Step 3: Creating a service
Build a service to handle server interactions and retain user information.
// For the sake of the exercise
type User = any;
@Injectable({ providedIn: 'root' })
export class AuthService {
http = inject(HttpClient);
router = inject(Router);
user = signal<User | null>(null);
}
To retrieve the user’s information, a fetchUser method is required:
/**
* Gets the User's info from server and populates the state.
* This is _the_ way to check if the user is still logged in.
* If we already have it, skip the call.
*/
fetchUser(forceReload = false): Observable<User> {
const user = this.user();
if (!!user && !forceReload) return of(user);
return this.http.get<any>(`${env.apiUrl}/me`, {}).pipe(
tap(u => this.user.set(u))
);
}
Then we need register and a login methods:
register(credentials: {
email: string,
password: string,
name: string,
surname: string
}) {
return this.http.post<boolean>(`${env.apiUrl}/register`, credentials);
}
login(email: string, password: string) {
return this.http.post<any>(`${env.apiUrl}/login`, { email, password }).pipe(
switchMap(() => this.fetchUser()),
);
}
Finally, a logout method:
logout() {
this.http.get<any>(`${env.apiUrl}/logout`).subscribe(() => {
this.user.set(null);
this.router.navigateByUrl('/login');
});
}
You might be surprised, but we're nearly finished here!
Step 4: Detect an expired cookie
A 401 response signals that our session has expired, which calls for a redirect to the login screen. So, we'll set up one more interceptor—this is merely a starting point, don't hesitate to adapt it to your needs!
export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
const authService = inject(AuthService);
return next(req).pipe(
tap({
error: error => {
if (error instanceof HttpErrorResponse && error.status === 401) {
// Clears the cookies and redirects to login
authService.logout();
}
}
})
);
}
And provide it:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([apiInterceptor, authInterceptor])
),
// ...
]
};
Step 5: Guard your pages!
Now build a guard that verifies the user's authentication status, and when they aren't logged in, sends them over to the login screen:
export const authGuard = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.fetchUser().pipe(
map(() => true),
catchError(() => {
router.navigateByUrl('/login')
return [false];
});
)
}
Add this guard to any routes that need protection, but be careful to leave it off the login and registration pages!
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadChildren: () => import('./pages/home/home.routes'),
canMatch: [authGuard]
},
// ...
]
Put it into practice
That's all there is to it. Now you only need to set up a couple of views for the login and registration forms, plus display the logged-in user's details.
Use this pattern when signing up:
this.authService.register(credentials).subscribe(() => {
this.router.navigateByUrl('/login');
})
You login like this:
this.authService.login(email, password).subscribe(() => {
this.router.navigateByUrl('/');
})
Bonus: SSR Quirks
If server-side rendering is not part of your setup, feel free to skip ahead. However, for those using SSR, a few peculiarities deserve attention.
We deliberately omitted the CSRF Token request on the server side. Had we included it, Angular would have cached that call and never repeated it on the client—an outcome we want to avoid.
Another SSR concern involves forwarding Cookies.
In a purely client-rendered application, every API call originates from the user's browser, where cookies reside; hence, they travel along with each request automatically.
With SSR, however, requests may originate from the server, and the user's cookies are absent. Consequently, any authentication-dependent request (such as /me for fetching the user profile) is bound to fail server-side. This forces the server to render an unauthenticated page (e.g., login buttons appear, or a guard redirects to the login route), after which the client-side app takes over and patches the HTML—a poor experience indeed.
Angular won't relay those cookies on its own. To address this, you must extract the user's cookies from the initial NodeJS Express Request and manually append them to every outgoing call.
Start by supplying the Express Request object to Angular—while we're at it, also hand over the Response. To accomplish this, define some InjectionTokens and register them during the app's render.
Place the following into a file named express-tokens.ts:
import { InjectionToken } from "@angular/core";
import { Request, Response } from "express";
export const REQUEST = new InjectionToken<Request>('Express REQUEST');
export const RESPONSE = new InjectionToken<Response>('Express RESPONSE');
Provide them in server.ts:
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl },
// Add these lines
{ provide: REQUEST, useValue: req },
{ provide: RESPONSE, useValue: res }
],
})
.then((html) => res.send(html))
.catch((err) => next(err));
Once the interceptor is ready, leverage it to extract the cookies from the Request object and attach them as headers:
import { isPlatformServer } from '@angular/common';
import { HttpHandlerFn, HttpHeaders, HttpRequest } from '@angular/common/http';
import { PLATFORM_ID, inject } from '@angular/core';
import { REQUEST } from '../../express-tokens';
export function cookieInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn
) {
const location = inject(PLATFORM_ID);
const serverRequest = inject(REQUEST, { optional: true });
if (isPlatformServer(location)) {
let headers = new HttpHeaders();
const cookies = serverRequest?.headers.cookie;
headers = headers.set('cookie', cookies ?? '');
const cookiedRequest = req.clone({
headers,
});
return next(cookiedRequest);
}
return next(req);
}
But this won’t work out of the box — you’ll likely see an error that looks like "Refused to set unsafe header 'cookie'". The reason is simple: the specification disallows using "cookie" as a header name. To get past this, we need to apply a small workaround within server.ts that turns off that restriction:
// @ts-ignore
import * as xhr2 from 'xhr2';
// HACK - enables setting cookie header
xhr2.prototype._restrictedHeaders.cookie = false;
That wraps it up.
Should you happen to supply
withFetch()to theHttpClient, this approach won't succeed. Keep an eye on this unresolved issue for further details.


