Authentication is the first thing users see when they open an admin dashboard. It's also the part most tutorials get wrong — either oversimplifying to the point of being unusable in production or overcomplicating with libraries that add more problems than they solve.
Here's how to build authentication for an Angular 22 admin dashboard properly — JWT tokens, route guards, HTTP interceptors, and a login page that actually looks and works correctly.
The Authentication Architecture
Angular 22 authentication involves four pieces:
AuthService — manages token storage, user state, login/logout logic. Everything authentication-related goes here.
authGuard — functional route guard that protects routes. Redirects to login if not authenticated.
authInterceptor — functional HTTP interceptor that adds JWT token to every outgoing request automatically.
Login component — the UI. Signal Forms, Bootstrap 5.3, proper error handling.
AuthService
// core/services/auth.service.ts
import { Injectable, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Router } from '@angular/router'
import { tap, catchError } from 'rxjs/operators'
import { throwError } from 'rxjs'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
avatar?: string
}
export interface LoginCredentials {
email: string
password: string
}
export interface AuthResponse {
token: string
refreshToken: string
user: User
expiresIn: number
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly TOKEN_KEY = 'auth_token'
private readonly REFRESH_KEY = 'refresh_token'
private readonly USER_KEY = 'auth_user'
// Signal-based state
private _user = signal<User | null>(this.loadStoredUser())
private _isLoading = signal(false)
private _error = signal<string | null>(null)
// Public read-only signals
user = this._user.asReadonly()
isLoading = this._isLoading.asReadonly()
error = this._error.asReadonly()
// Derived state
isAuthenticated = computed(() => this._user() !== null)
isAdmin = computed(() => this._user()?.role === 'admin')
userName = computed(() => this._user()?.name ?? '')
constructor(
private http: HttpClient,
private router: Router
) {}
login(credentials: LoginCredentials) {
this._isLoading.set(true)
this._error.set(null)
return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
tap(response => {
this.storeTokens(response.token, response.refreshToken)
this.storeUser(response.user)
this._user.set(response.user)
this._isLoading.set(false)
}),
catchError(error => {
this._error.set(
error.error?.message ?? 'Invalid email or password'
)
this._isLoading.set(false)
return throwError(() => error)
})
)
}
logout() {
localStorage.removeItem(this.TOKEN_KEY)
localStorage.removeItem(this.REFRESH_KEY)
localStorage.removeItem(this.USER_KEY)
this._user.set(null)
this.router.navigate(['/auth/login'])
}
getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY)
}
hasPermission(permission: string): boolean {
const role = this._user()?.role
const permissions: Record<string, string[]> = {
admin: ['read', 'write', 'delete', 'admin'],
editor: ['read', 'write'],
viewer: ['read']
}
return role ? permissions[role]?.includes(permission) ?? false : false
}
private storeTokens(token: string, refreshToken: string) {
localStorage.setItem(this.TOKEN_KEY, token)
localStorage.setItem(this.REFRESH_KEY, refreshToken)
}
private storeUser(user: User) {
localStorage.setItem(this.USER_KEY, JSON.stringify(user))
}
private loadStoredUser(): User | null {
try {
const stored = localStorage.getItem(this.USER_KEY)
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
}
Signal-based state throughout. isAuthenticated and isAdmin computed from user Signal. hasPermission method for role-based access control.
authGuard
// core/guards/auth.guard.ts
import { inject } from '@angular/core'
import { CanActivateFn, Router } from '@angular/router'
import { AuthService } from '../services/auth.service'
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (authService.isAuthenticated()) {
return true
}
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url }
})
}
// Admin-only guard
export const adminGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url }
})
}
if (!authService.isAdmin()) {
return router.createUrlTree(['/unauthorized'])
}
return true
}
Two guards — authGuard for any authenticated user, adminGuard for admin-only routes. Functional guards — no class, no interface. inject() for dependencies.
authInterceptor
// core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'
import { inject } from '@angular/core'
import { catchError, throwError } from 'rxjs'
import { AuthService } from '../services/auth.service'
import { Router } from '@angular/router'
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService)
const router = inject(Router)
const token = authService.getToken()
// Skip auth header for login/register endpoints
const isAuthEndpoint = req.url.includes('/auth/login') ||
req.url.includes('/auth/register')
const authReq = token && !isAuthEndpoint
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
})
: req
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
// Auto-logout on 401 Unauthorized
if (error.status === 401) {
authService.logout()
router.navigate(['/auth/login'])
}
return throwError(() => error)
})
)
}
Skips auth header for auth endpoints. Adds Bearer token to everything else. Auto-logout on 401 response — handles expired tokens without manual checking.
Routes With Guards Applied
// app.routes.ts
export const routes: Routes = [
{
path: 'auth',
children: [
{
path: 'login',
loadComponent: () =>
import('./features/auth/login/login.component')
.then(m => m.LoginComponent)
},
{
path: 'forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password.component')
.then(m => m.ForgotPasswordComponent)
}
]
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
canActivate: [authGuard]
},
{
path: 'admin',
loadComponent: () =>
import('./features/admin/admin.component')
.then(m => m.AdminComponent),
canActivate: [adminGuard] // admin only
},
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
}
]
Auth routes unguarded. Dashboard guarded by authGuard. Admin section guarded by adminGuard. All lazy loaded.
Login Component With Signal Forms
// features/auth/login/login.component.ts
import { Component, inject, computed, signal } from '@angular/core'
import { Router, ActivatedRoute } from '@angular/router'
import { FormGroup, FormControl, Validators } from '@angular/forms/signals'
import { AuthService } from '../../../core/services/auth.service'
@Component({
selector: 'app-login',
standalone: true,
templateUrl: './login.component.html'
})
export class LoginComponent {
private authService = inject(AuthService)
private router = inject(Router)
private route = inject(ActivatedRoute)
// Signal Forms
form = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email
]),
password: new FormControl('', [
Validators.required,
Validators.minLength(8)
]),
rememberMe: new FormControl(false)
})
// State from service
isLoading = this.authService.isLoading
serverError = this.authService.error
passwordVisible = signal(false)
// Validation signals
emailError = computed(() => {
const control = this.form.controls.email
if (!control.touched()) return null
if (control.hasError('required')) return 'Email is required'
if (control.hasError('email')) return 'Enter a valid email address'
return null
})
passwordError = computed(() => {
const control = this.form.controls.password
if (!control.touched()) return null
if (control.hasError('required')) return 'Password is required'
if (control.hasError('minlength')) return 'Password must be at least 8 characters'
return null
})
isFormValid = computed(() => this.form.valid())
togglePassword() {
this.passwordVisible.update(v => !v)
}
onSubmit() {
if (!this.form.valid()) {
Object.values(this.form.controls).forEach(control => {
control.markAsTouched()
})
return
}
const { email, password } = this.form.value()
this.authService.login({ email, password }).subscribe({
next: () => {
const returnUrl = this.route.snapshot
.queryParams['returnUrl'] || '/dashboard'
this.router.navigateByUrl(returnUrl)
}
})
}
}
<!-- features/auth/login/login.component.html -->
<div class="auth-wrapper min-vh-100 d-flex align-items-center
justify-content-center bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
<!-- Logo -->
<div class="text-center mb-4">
<a href="/" class="d-inline-block">
<img src="assets/images/logo.svg" alt="Logo" height="40">
</a>
</div>
<!-- Card -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- Header -->
<div class="text-center mb-4">
<h1 class="h4 fw-bold mb-1">Welcome back</h1>
<p class="text-muted small mb-0">Sign in to your account</p>
</div>
<!-- Server Error -->
@if (serverError()) {
<div class="alert alert-danger d-flex align-items-center
gap-2 py-2 mb-4" role="alert">
<i class="bx bx-error-circle fs-5"></i>
<span class="small">{{ serverError() }}</span>
</div>
}
<!-- Form -->
<form (ngSubmit)="onSubmit()" novalidate>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label fw-medium small">
Email address
</label>
<input
type="email"
id="email"
class="form-control"
[class.is-invalid]="emailError()"
[class.is-valid]="!emailError() &&
form.controls.email.touched()"
placeholder="you@example.com"
[value]="form.controls.email.value()"
(input)="form.controls.email
.setValue($any($event.target).value)"
(blur)="form.controls.email.markAsTouched()"
autocomplete="email">
@if (emailError()) {
<div class="invalid-feedback">{{ emailError() }}</div>
}
</div>
<!-- Password -->
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<label for="password"
class="form-label fw-medium small mb-0">
Password
</label>
<a href="/auth/forgot-password"
class="small text-primary text-decoration-none">
Forgot password?
</a>
</div>
<div class="input-group">
<input
[type]="passwordVisible() ? 'text' : 'password'"
id="password"
class="form-control border-end-0"
[class.is-invalid]="passwordError()"
placeholder="••••••••"
[value]="form.controls.password.value()"
(input)="form.controls.password
.setValue($any($event.target).value)"
(blur)="form.controls.password.markAsTouched()"
autocomplete="current-password">
<button type="button"
class="input-group-text bg-white border-start-0"
(click)="togglePassword()">
<i class="bx fs-5"
[class.bx-show]="!passwordVisible()"
[class.bx-hide]="passwordVisible()"></i>
</button>
</div>
@if (passwordError()) {
<div class="text-danger small mt-1">
{{ passwordError() }}
</div>
}
</div>
<!-- Remember Me -->
<div class="mb-4">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="rememberMe">
<label class="form-check-label small" for="rememberMe">
Remember me for 30 days
</label>
</div>
</div>
<!-- Submit -->
<button
type="submit"
class="btn btn-primary w-100 py-2"
[disabled]="isLoading()">
@if (isLoading()) {
<span class="spinner-border spinner-border-sm me-2"
role="status"></span>
Signing in...
} @else {
Sign In
}
</button>
</form>
<!-- Divider -->
<div class="d-flex align-items-center my-4">
<hr class="flex-grow-1">
<span class="px-3 text-muted small">or</span>
<hr class="flex-grow-1">
</div>
<!-- Sign Up Link -->
<p class="text-center text-muted small mb-0">
Don't have an account?
<a href="/auth/register"
class="text-primary fw-medium text-decoration-none">
Create account
</a>
</p>
</div>
</div>
<!-- Footer -->
<p class="text-center text-muted small mt-4">
Protected by SSL encryption
</p>
</div>
</div>
</div>
</div>
Forgot Password Component
// features/auth/forgot-password/forgot-password.component.ts
import { Component, inject, signal } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { FormControl, Validators } from '@angular/forms/signals'
@Component({
selector: 'app-forgot-password',
standalone: true,
template: `
<div class="auth-wrapper min-vh-100 d-flex align-items-center
justify-content-center bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-5">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 p-md-5">
@if (!submitted()) {
<div class="text-center mb-4">
<div class="bg-primary-subtle rounded-circle
d-inline-flex p-3 mb-3">
<i class="bx bx-lock-open text-primary fs-3"></i>
</div>
<h1 class="h5 fw-bold mb-1">Forgot your password?</h1>
<p class="text-muted small">
Enter your email and we'll send a reset link.
</p>
</div>
<form (ngSubmit)="onSubmit()">
<div class="mb-3">
<input type="email"
class="form-control"
placeholder="your@email.com"
[value]="email.value()"
(input)="email.setValue(
$any($event.target).value
)"
required>
</div>
<button type="submit"
class="btn btn-primary w-100"
[disabled]="isLoading()">
@if (isLoading()) {
<span class="spinner-border spinner-border-sm me-2">
</span>
Sending...
} @else {
Send Reset Link
}
</button>
</form>
} @else {
<div class="text-center">
<div class="bg-success-subtle rounded-circle
d-inline-flex p-3 mb-3">
<i class="bx bx-check text-success fs-3"></i>
</div>
<h2 class="h5 fw-bold mb-2">Check your email</h2>
<p class="text-muted small">
We sent a reset link to
<strong>{{ email.value() }}</strong>
</p>
<a href="/auth/login"
class="btn btn-outline-primary mt-2">
Back to login
</a>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
`
})
export class ForgotPasswordComponent {
email = new FormControl('', [Validators.required, Validators.email])
isLoading = signal(false)
submitted = signal(false)
private http = inject(HttpClient)
onSubmit() {
if (!this.email.valid()) return
this.isLoading.set(true)
this.http.post('/api/auth/forgot-password', {
email: this.email.value()
}).subscribe({
next: () => {
this.submitted.set(true)
this.isLoading.set(false)
},
error: () => this.isLoading.set(false)
})
}
}
Two states — form and success confirmation. Clean UX for password reset flows.
Register appConfig With Interceptor
// app.config.ts
import { ApplicationConfig } from '@angular/core'
import { provideRouter } from '@angular/router'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { routes } from './app.routes'
import { authInterceptor } from './core/interceptors/auth.interceptor'
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
}
One line to register the interceptor. Every HTTP request in the app automatically gets the JWT token attached.
Browse Angular 22 admin dashboard templates with authentication already implemented at LettStart Design — auth guards, interceptors, login pages, Signal Forms, Bootstrap 5.3. Use FIRST30 for 30% off.
Top comments (0)