Table of Contents
- Prerequisites
- Setting Up the Angular Project
- Authentication Flow Overview
- Storing Authentication Tokens Securely
- Creating an Authentication Service
- Protecting Routes with Route Guards
- Building Login and Registration Components
- Handling Token Expiration and Refresh Tokens
- Managing Authentication State
- Security Best Practices
- Testing Authentication
- Troubleshooting Common Issues
- Conclusion
- References
Prerequisites
Before diving in, ensure you have the following:
- Basic knowledge of Angular (components, services, routing, RxJS).
- Angular CLI installed (
npm install -g @angular/cli). - A backend API that supports authentication (e.g., Node.js/Express, Firebase, or a third-party service like Auth0). For this guide, we’ll assume a REST API with endpoints like
/api/login,/api/register, and/api/refresh-token. - Node.js and npm/yarn installed.
Setting Up the Angular Project
Let’s start by creating a new Angular project and installing dependencies:
# Create a new Angular app
ng new angular-auth-demo --routing --style=css
# Navigate to the project
cd angular-auth-demo
# Install required dependencies (e.g., for reactive forms, HTTP client)
# Angular already includes HttpClientModule and ReactiveFormsModule by default, but ensure they’re imported.
Next, enable routing by adding AppRoutingModule to app.module.ts (Angular CLI does this automatically if you use --routing).
Authentication Flow Overview
A typical authentication flow in an Angular app follows these steps:
- User submits credentials (username/email and password) via a login form.
- Angular sends credentials to the backend API via an HTTP POST request.
- Backend validates credentials and returns a JSON Web Token (JWT) or session token, along with a refresh token (optional).
- Angular stores the token securely (e.g., in HttpOnly cookies or localStorage).
- Token is attached to subsequent requests (via HTTP interceptors) to authenticate the user.
- Route guards protect sensitive routes by checking if the user has a valid token.
- On logout, the token is cleared, and the user is redirected to the login page.
- Token expiration is handled by refreshing the token using a refresh token before it expires.
Storing Authentication Tokens Securely
Storing tokens securely is critical to prevent unauthorized access. Here are the most common methods:
1. HttpOnly Cookies
- How it works: The backend sets the token in an
HttpOnlycookie via theSet-Cookieheader. Browsers automatically include cookies in subsequent requests to the same domain. - Pros: Protected against XSS attacks (JavaScript cannot access HttpOnly cookies).
- Cons: Requires backend configuration; may complicate CORS setup; less flexible for SPAs with separate frontend/backend domains.
2. localStorage/sessionStorage
- How it works: Tokens are stored in the browser’s
localStorage(persists across sessions) orsessionStorage(cleared when the tab closes). - Pros: Easy to implement; accessible via JavaScript for attaching to requests.
- Cons: Vulnerable to XSS attacks (malicious scripts can steal tokens).
3. In-Memory Storage
- How it works: Tokens are stored in a service variable (e.g., a
BehaviorSubject). - Pros: Not accessible via JavaScript (safer than
localStorage). - Cons: Lost on page refresh; requires re-authentication after reload.
Recommendation: Use HttpOnly cookies for production to mitigate XSS risks. For simplicity in this guide, we’ll use localStorage, but we’ll highlight security caveats.
Creating an Authentication Service
The authentication service centralizes logic for login, logout, token management, and checking authentication status. Let’s create one:
ng generate service auth
Update auth.service.ts with the following:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Router } from '@angular/router';
interface AuthResponse {
accessToken: string;
refreshToken: string;
user: { id: string; email: string; name: string };
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUserSubject: BehaviorSubject<AuthResponse['user'] | null>;
public currentUser$: Observable<AuthResponse['user'] | null>;
constructor(private http: HttpClient, private router: Router) {
// Initialize with stored user data (if any)
const storedUser = localStorage.getItem('currentUser');
this.currentUserSubject = new BehaviorSubject<AuthResponse['user'] | null>(
storedUser ? JSON.parse(storedUser) : null
);
this.currentUser$ = this.currentUserSubject.asObservable();
}
// Get the current user (synchronously)
public get currentUserValue(): AuthResponse['user'] | null {
return this.currentUserSubject.value;
}
// Login user
login(email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${environment.apiUrl}/login`, { email, password })
.pipe(
tap(response => this.handleAuthentication(response)),
catchError(error => {
// Handle errors (e.g., invalid credentials)
return throwError(() => new Error(error.error.message || 'Login failed'));
})
);
}
// Register user
register(name: string, email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${environment.apiUrl}/register`, { name, email, password })
.pipe(
tap(response => this.handleAuthentication(response)),
catchError(error => throwError(() => new Error(error.error.message || 'Registration failed')))
);
}
// Logout user
logout(): void {
// Clear tokens and user data
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('currentUser');
this.currentUserSubject.next(null);
this.router.navigate(['/login']);
}
// Check if user is authenticated
isAuthenticated(): boolean {
const token = localStorage.getItem('accessToken');
return !!token && !this.isTokenExpired(token); // Implement isTokenExpired()
}
// Helper: Store tokens and user data
private handleAuthentication(authResponse: AuthResponse): void {
const { accessToken, refreshToken, user } = authResponse;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('currentUser', JSON.stringify(user));
this.currentUserSubject.next(user);
}
// Helper: Check if token is expired (simplified example)
private isTokenExpired(token: string): boolean {
try {
const decoded: any = JSON.parse(atob(token.split('.')[1]));
return decoded.exp * 1000 < Date.now(); // exp is in seconds
} catch (error) {
return true; // Treat invalid tokens as expired
}
}
// Get access token for HTTP requests
getAccessToken(): string | null {
return localStorage.getItem('accessToken');
}
// Get refresh token
getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
}
Protecting Routes with Route Guards
Angular’s route guards control access to routes based on conditions (e.g., authentication status). We’ll use CanActivate to protect routes like /dashboard or /profile.
Step 1: Create an Auth Guard
ng generate guard auth
Choose CanActivate when prompted. Update auth.guard.ts:
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
if (this.authService.isAuthenticated()) {
return true; // Allow access to the route
}
// Redirect to login if not authenticated
this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
}
}
Step 2: Apply the Guard to Routes
Update app-routing.module.ts to protect sensitive routes:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './login/login.component';
import { RegisterComponent } from './register/register.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { AuthGuard } from './auth.guard';
const routes: Routes = [
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] },
{ path: '', redirectTo: '/login', pathMatch: 'full' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Building Login and Registration Components
Let’s create login and registration forms using Angular’s Reactive Forms for validation and state management.
Login Component
ng generate component login
Update login.component.ts:
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent {
loginForm: FormGroup;
errorMessage = '';
returnUrl: string;
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute
) {
// Initialize form with validators
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]]
});
// Get return URL from query params (e.g., /dashboard)
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
}
// Submit login form
onSubmit(): void {
if (this.loginForm.invalid) return;
const { email, password } = this.loginForm.value;
this.authService.login(email, password).subscribe({
next: () => {
this.router.navigate([this.returnUrl]); // Redirect to return URL
},
error: (err) => {
this.errorMessage = err.message; // Show error to user
}
});
}
}
Update login.component.html:
<div class="login-container">
<h2>Login</h2>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label>Email</label>
<input type="email" formControlName="email" class="form-control">
<div *ngIf="loginForm.get('email')?.invalid && loginForm.get('email')?.touched">
<small *ngIf="loginForm.get('email')?.errors?.['required']">Email is required</small>
<small *ngIf="loginForm.get('email')?.errors?.['email']">Invalid email format</small>
</div>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" formControlName="password" class="form-control">
<div *ngIf="loginForm.get('password')?.invalid && loginForm.get('password')?.touched">
<small *ngIf="loginForm.get('password')?.errors?.['required']">Password is required</small>
<small *ngIf="loginForm.get('password')?.errors?.['minLength']">Password must be at least 6 characters</small>
</div>
</div>
<button type="submit" [disabled]="loginForm.invalid" class="btn btn-primary">Login</button>
<div *ngIf="errorMessage" class="error-message">{{ errorMessage }}</div>
</form>
<p>Don't have an account? <a routerLink="/register">Register</a></p>
</div>
Registration Component
Follow a similar pattern for the registration component, adding fields for name and confirming the password.
Handling Token Expiration and Refresh Tokens
JWT tokens have an expiration time (exp claim). To avoid forcing users to log in repeatedly, use a refresh token to fetch a new access token.
Step 1: Create an HTTP Interceptor
HTTP interceptors modify outgoing requests (e.g., add tokens) and incoming responses (e.g., handle 401 errors).
ng generate interceptor auth
Update auth.interceptor.ts:
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, filter, take, switchMap } from 'rxjs/operators';
import { AuthService } from './auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private isRefreshing = false;
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
constructor(private authService: AuthService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getAccessToken();
if (token) {
request = this.addToken(request, token);
}
return next.handle(request).pipe(
catchError(error => {
if (error instanceof HttpErrorResponse && error.status === 401) {
return this.handle401Error(request, next);
}
return throwError(() => error);
})
);
}
private addToken(request: HttpRequest<any>, token: string): HttpRequest<any> {
return request.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
const refreshToken = this.authService.getRefreshToken();
if (refreshToken) {
return this.authService.refreshToken(refreshToken).pipe(
switchMap((token: any) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(token.accessToken);
return next.handle(this.addToken(request, token.accessToken));
}),
catchError((err) => {
this.isRefreshing = false;
this.authService.logout(); // Logout on refresh token failure
return throwError(() => err);
})
);
}
}
// Wait for the refresh token to be available
return this.refreshTokenSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(token => next.handle(this.addToken(request, token)))
);
}
}
Step 2: Add Refresh Token Logic to AuthService
Update auth.service.ts to include a refreshToken method:
// Add to AuthService
refreshToken(refreshToken: string): Observable<{ accessToken: string }> {
return this.http.post<{ accessToken: string }>(`${environment.apiUrl}/refresh-token`, { refreshToken })
.pipe(
tap(response => {
localStorage.setItem('accessToken', response.accessToken);
})
);
}
Step 3: Register the Interceptor
Add AuthInterceptor to app.module.ts providers:
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth.interceptor';
@NgModule({
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
})
export class AppModule { }
Managing Authentication State
For larger apps, use a state management library like NgRx. For smaller apps, a BehaviorSubject in AuthService (as shown earlier) suffices. The currentUser$ observable allows components to react to authentication state changes:
// In a component (e.g., header)
this.authService.currentUser$.subscribe(user => {
this.isLoggedIn = !!user;
this.userName = user?.name;
});
Security Best Practices
- Use HTTPS: Always transmit tokens over HTTPS to prevent interception.
- Avoid localStorage for tokens: Prefer HttpOnly cookies to mitigate XSS risks.
- Short-lived access tokens: Use short expiration times (e.g., 15 minutes) and refresh tokens for re-authentication.
- CSRF protection: If using cookies, enable CSRF tokens (Angular’s
HttpClientsupports CSRF withwithCredentials). - Input validation: Validate all user inputs on both client and server.
- Sanitize inputs: Prevent injection attacks by sanitizing user-generated content.
- Rate limiting: Implement rate limiting on login/register endpoints to prevent brute-force attacks.
Testing Authentication
Test auth services, guards, and interceptors using Angular’s testing utilities:
// Example: Testing AuthService login method
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService]
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
it('should login and store tokens', () => {
const mockResponse = {
accessToken: 'test-token',
refreshToken: 'refresh-token',
user: { id: '1', name: 'Test User', email: '[email protected]' }
};
service.login('[email protected]', 'password123').subscribe(response => {
expect(response).toEqual(mockResponse);
expect(localStorage.getItem('accessToken')).toBe('test-token');
});
const req = httpMock.expectOne(`${environment.apiUrl}/login`);
expect(req.request.method).toBe('POST');
req.flush(mockResponse);
});
});
Troubleshooting Common Issues
- CORS errors: Ensure the backend allows requests from your Angular app’s origin (set
Access-Control-Allow-Origin). - Token not attached to requests: Verify the interceptor is registered in
app.module.ts. - Guard not working: Check if
AuthGuardis correctly added to the route’scanActivatearray. - Refresh token loops: Ensure the backend returns a 401 only for invalid/expired tokens, not refresh tokens.
Conclusion
Implementing user authentication in Angular requires careful attention to security, token management, and user experience. By following this guide, you’ve learned how to:
- Set up secure token storage and transmission.
- Protect routes with guards.
- Handle token expiration with refresh tokens.
- Secure API requests with interceptors.
- Follow best practices to mitigate common security threats.
Remember that authentication is a shared responsibility between frontend and backend—always validate credentials server-side and stay updated on security vulnerabilities.