User Access Control in Angular

By Syncfusion®, Inc. · Last updated

Authentication and authorization in Angular: JWTs, an auth service, registration and login, token handling and expiration, interceptors, roles, route guards, error handling and logout, with a full capstone project.

Authentication Introduction

Authentication checks who the user is. The user proves their identity, usually with an email and a password, and the app then knows: "This is Asha."

Common ways to authenticate:

  • Email and password
  • A code sent by text message or email
  • Login with another account, such as Google or Microsoft

Real-World Example

In an online banking app, a customer logs in with their customer ID and password. After that, they can see their own accounts. They cannot see other customers' accounts, and only bank staff can open the screen that approves loans.

The login is authentication. The rules about which screens each person may open are authorization.

Authorization Introduction

Authorization checks what a user may do. It always comes after authentication: first the app knows who you are, then it decides what you may open or change.

PointAuthenticationAuthorization
QuestionWho are you?What may you do?
ExampleLog in with email and passwordOnly admins may delete users
Fails with401 Unauthorized: not logged in403 Forbidden: logged in, but not allowed

1. Loginemail + password2. Serverchecks, sends JWT3. Interceptoradds the token4. Serverchecks token + roleauthenticationauthorization: 200, 401 or 403

Authentication first, then authorization on every request.

JWT Authentication

After a successful login, the server needs a way to recognize the user in later requests. Most Angular apps use a JWT (JSON Web Token): a text that the server creates and signs after the login.

Headeralg, typ.Payloadname, role, exp.Signaturemade with the secret keyHeader and payload can be decoded by anyone. The signature proves the server made the token.

The three parts of a JSON Web Token.

A JWT has three parts, separated by dots:

PartContainsExample content
HeaderThe type of token and the signing method{ "alg": "HS256", "typ": "JWT" }
PayloadData about the user, called claims{ "name": "Asha Rao", "role": "user", "exp": 1924992000 }
SignatureA code made with the server's secret keySflKxwRJ...

The header and payload are only encoded in Base64, a way to write data as plain letters and numbers. Anyone can decode them. The signature proves that the server created the token: if someone changes the payload, the signature no longer matches, and the server rejects the token.

The flow:

  1. The user logs in. The server checks the password and sends back a token.
  2. The app stores the token.
  3. The app sends the token with every request, in the header Authorization: Bearer <token>.
  4. The server checks the signature and the expiration time, and answers.

The next example decodes a JWT. atob() is a browser function that decodes Base64.

Reading the payload of a JWT

import { Component } from '@angular/core';

const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjIsIm5hbWUiOiJBc2hhIFJhbyIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxOTI0OTkyMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

function decodeToken(token: string) {
  const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(payload));
}

@Component({
  selector: 'app-root',
  template: `
    <p>Header: {{ parts[0] }}</p>
    <p>Payload: {{ parts[1] }}</p>
    <p>Signature: {{ parts[2] }}</p>
    <hr />
    <p>Name: {{ user.name }}</p>
    <p>Role: {{ user.role }}</p>
    <p>Expires: {{ expires }}</p>
  `,
})
export class App {
  parts = TOKEN.split('.');
  user = decodeToken(TOKEN);
  expires = new Date(this.user.exp * 1000).toISOString().slice(0, 10);
}

exp is the expiration time in seconds since 1 January 1970. JavaScript dates use milliseconds, so the code multiplies it by 1000.

Never put secret data, such as a password, into a JWT payload. Anyone who has the token can decode and read it.

Auth Service

Keep all authentication code in one service, AuthService, as you learned in the State Management lesson. It has:

  • State: the token, kept in a private signal, and values computed from it, such as user and isLoggedIn.
  • Methods: register(), login() and logout().
@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private readonly _token = signal<string | null>(null);

  readonly token = this._token.asReadonly();
  readonly user = computed(() => {
    const token = this._token();
    return token ? decodeToken(token) : null;
  });
  readonly isLoggedIn = computed(() => this.user() !== null);

  login(email: string, password: string) { /* ... */ }
  register(name: string, email: string, password: string) { /* ... */ }
  logout() { /* ... */ }
}

User Registration

Registration creates a new account. The app sends the name, email and password with a POST request. Many servers answer with a token, so the new user is logged in at once.

register(name: string, email: string, password: string) {
  return this.http
    .post<{ token: string }>('/api/auth/register', { name, email, password })
    .pipe(tap(res => this._token.set(res.token)));
}

tap() stores the token when the answer arrives, and the component still gets the answer. The server must check the data, refuse an email that already exists, and store the password only in a scrambled form (a hash), never as plain text.

User Login

Login works the same way: send the email and password, and store the token from the answer.

Login and authentication state

import { ApplicationConfig, Component, Injectable, computed, inject, signal } from '@angular/core';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, tap, throwError, timer } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

export function decodeToken(token: string) {
  return JSON.parse(atob(token.split('.')[1]));
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private readonly _token = signal<string | null>(null);

  readonly user = computed(() => {
    const token = this._token();
    return token ? decodeToken(token) : null;
  });
  readonly isLoggedIn = computed(() => this.user() !== null);

  login(email: string, password: string) {
    return this.http
      .post<{ token: string }>('/api/auth/login', { email, password })
      .pipe(tap(res => this._token.set(res.token)));
  }

  logout() {
    this._token.set(null);
  }
}

@Component({
  selector: 'app-root',
  template: `
    <button (click)="login('user123')">Log in as Asha</button>
    <button (click)="login('wrong')">Log in with a wrong password</button>
    <button (click)="auth.logout()">Log out</button>
    <p>Logged in: {{ auth.isLoggedIn() }}</p>
    <p>User: {{ auth.user()?.name ?? '-' }} ({{ auth.user()?.role ?? '-' }})</p>
    @if (error()) {
      <p>⚠ {{ error() }}</p>
    }
  `,
})
export class App {
  auth = inject(AuthService);
  error = signal('');

  login(password: string) {
    this.error.set('');
    this.auth.login('asha@shop.com', password).subscribe({
      error: (err: HttpErrorResponse) => this.error.set(err.error.message),
    });
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the auth API inside the browser. A real server signs the token
// with a secret key; this practice server only imitates the format.
const ACCOUNTS = [
  { name: 'Asha Rao', email: 'asha@shop.com', password: 'user123', role: 'user' },
  { name: 'Admin', email: 'admin@shop.com', password: 'admin123', role: 'admin' },
];

function makeToken(account: any, seconds: number) {
  const encode = (data: object) => btoa(JSON.stringify(data));
  const exp = Math.floor(Date.now() / 1000) + seconds;
  const payload = { name: account.name, email: account.email, role: account.role, exp };
  return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.practice-signature`;
}

function readToken(req: HttpRequest<any>) {
  const header = req.headers.get('Authorization') ?? '';
  try {
    const payload = JSON.parse(atob(header.replace('Bearer ', '').split('.')[1]));
    return payload.exp * 1000 > Date.now() ? payload : null;
  } catch {
    return null;
  }
}

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  const body = req.body ?? {};
  if (req.url === '/api/auth/register') {
    if (ACCOUNTS.some(a => a.email === body.email)) return fail(409, 'This email is already registered.');
    const account = { name: body.name, email: body.email, password: body.password, role: 'user' };
    ACCOUNTS.push(account);
    return reply({ token: makeToken(account, 900) });
  }
  if (req.url === '/api/auth/login') {
    const account = ACCOUNTS.find(a => a.email === body.email && a.password === body.password);
    return account ? reply({ token: makeToken(account, body.seconds ?? 900) }) : fail(401, 'Wrong email or password.');
  }
  const user = readToken(req);
  if (!user) return fail(401, 'Please log in.');
  if (req.url === '/api/me') return reply(user);
  if (req.url === '/api/admin/report') {
    return user.role === 'admin' ? reply({ sales: 12400, orders: 310 }) : fail(403, 'Administrators only.');
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(200));
}

function fail(status: number, message: string) {
  return timer(200).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Authentication State

The authentication state is everything the app knows about the current login: the token, the user, and whether someone is logged in. Many components need it, for example the header shows "Log out" only for a logged-in user.

Because the state is in signals, every component that reads auth.isLoggedIn() or auth.user() updates when the user logs in or out.

Token Handling

The token is lost when the page reloads, because signals live only in memory. To keep the user logged in, save the token in the browser:

localStorage.setItem('auth_token', token);              // after login
const saved = localStorage.getItem('auth_token');       // when the app starts
localStorage.removeItem('auth_token');                  // on logout
Where to store the tokenSurvives a reloadRisk
Only in memory (a signal)NoLowest. The user must log in again after a reload
localStorageYesMalicious script code on the page could read it
An HttpOnly cookie set by the serverYesJavaScript cannot read it at all. The server must be set up for cookies

The downloadable project uses localStorage, because it is simple. For apps with sensitive data, ask your back-end team for HttpOnly cookies.

HTTP Interceptor

The server needs the token in every request. Do not add it by hand in every service. An interceptor, which you learned in the API Integration lesson, adds it to all requests:

An interceptor sends the token

import { ApplicationConfig, Component, Injectable, computed, inject, signal } from '@angular/core';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, tap, throwError, timer } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private readonly _token = signal<string | null>(null);
  readonly token = this._token.asReadonly();
  readonly isLoggedIn = computed(() => this._token() !== null);

  login() {
    return this.http
      .post<{ token: string }>('/api/auth/login', { email: 'asha@shop.com', password: 'user123' })
      .pipe(tap(res => this._token.set(res.token)));
  }
}

// Adds "Authorization: Bearer <token>" when the user is logged in
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  if (!token) {
    return next(req);
  }
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([authInterceptor, practiceServer]))],
};

@Component({
  selector: 'app-root',
  template: `
    <p>Logged in: {{ auth.isLoggedIn() }}</p>
    <button (click)="loadProfile()">Load my profile</button>
    <button (click)="auth.login().subscribe()">Log in</button>
    <p>{{ result() }}</p>
  `,
})
export class App {
  auth = inject(AuthService);
  private http = inject(HttpClient);
  result = signal('');

  loadProfile() {
    this.http.get<any>('/api/me').subscribe({
      next: user => this.result.set(`Hello, ${user.name}!`),
      error: (err: HttpErrorResponse) => this.result.set(`Error ${err.status}: ${err.error.message}`),
    });
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the auth API inside the browser. A real server signs the token
// with a secret key; this practice server only imitates the format.
const ACCOUNTS = [
  { name: 'Asha Rao', email: 'asha@shop.com', password: 'user123', role: 'user' },
  { name: 'Admin', email: 'admin@shop.com', password: 'admin123', role: 'admin' },
];

function makeToken(account: any, seconds: number) {
  const encode = (data: object) => btoa(JSON.stringify(data));
  const exp = Math.floor(Date.now() / 1000) + seconds;
  const payload = { name: account.name, email: account.email, role: account.role, exp };
  return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.practice-signature`;
}

function readToken(req: HttpRequest<any>) {
  const header = req.headers.get('Authorization') ?? '';
  try {
    const payload = JSON.parse(atob(header.replace('Bearer ', '').split('.')[1]));
    return payload.exp * 1000 > Date.now() ? payload : null;
  } catch {
    return null;
  }
}

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  const body = req.body ?? {};
  if (req.url === '/api/auth/register') {
    if (ACCOUNTS.some(a => a.email === body.email)) return fail(409, 'This email is already registered.');
    const account = { name: body.name, email: body.email, password: body.password, role: 'user' };
    ACCOUNTS.push(account);
    return reply({ token: makeToken(account, 900) });
  }
  if (req.url === '/api/auth/login') {
    const account = ACCOUNTS.find(a => a.email === body.email && a.password === body.password);
    return account ? reply({ token: makeToken(account, body.seconds ?? 900) }) : fail(401, 'Wrong email or password.');
  }
  const user = readToken(req);
  if (!user) return fail(401, 'Please log in.');
  if (req.url === '/api/me') return reply(user);
  if (req.url === '/api/admin/report') {
    return user.role === 'admin' ? reply({ sales: 12400, orders: 310 }) : fail(403, 'Administrators only.');
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(200));
}

function fail(status: number, message: string) {
  return timer(200).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Click Load my profile first: the server answers 401, because no token was sent. Click Log in, then Load my profile again.

Token Expiration

A JWT has an expiration time in its exp claim. After this time, the server rejects it with 401. Short times, such as 15 minutes, limit the damage if a token is stolen.

Logintoken with expTimer startsexp − nowTime is overtoken expiredlogout()"Session expired"The app logs out before the server starts to answer 401.

Automatic logout when the token expires.

The app should react before the user gets errors:

  • When the app starts, ignore a saved token that has already expired.
  • While the user works, start a timer when the token is stored, and log out when the time is over.
  • If the server answers 401, log out. The interceptor can do this for every request.

Automatic logout when the token expires

import { ApplicationConfig, Component, Injectable, computed, inject, signal } from '@angular/core';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

function decodeToken(token: string) {
  return JSON.parse(atob(token.split('.')[1]));
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private readonly _token = signal<string | null>(null);
  private expiryTimer: any;
  readonly isLoggedIn = computed(() => this._token() !== null);
  readonly message = signal('');
  readonly expiresAt = signal('');

  login() {
    // The practice server accepts "seconds" to create a short token.
    this.http
      .post<{ token: string }>('/api/auth/login', { email: 'asha@shop.com', password: 'user123', seconds: 5 })
      .subscribe(res => this.setToken(res.token));
  }

  logout(message = '') {
    clearTimeout(this.expiryTimer);
    this._token.set(null);
    this.message.set(message);
  }

  private setToken(token: string) {
    const exp = decodeToken(token).exp * 1000; // milliseconds
    this._token.set(token);
    this.message.set('');
    this.expiresAt.set(new Date(exp).toLocaleTimeString());
    clearTimeout(this.expiryTimer);
    this.expiryTimer = setTimeout(() => this.logout('Your session has expired. Please log in again.'), exp - Date.now());
  }
}

@Component({
  selector: 'app-root',
  template: `
    <button (click)="auth.login()">Log in (token valid for 5 seconds)</button>
    @if (auth.isLoggedIn()) {
      <p>✅ Logged in. The token expires at {{ auth.expiresAt() }}.</p>
    } @else {
      <p>Logged out.</p>
    }
    @if (auth.message()) {
      <p>⚠ {{ auth.message() }}</p>
    }
  `,
})
export class App {
  auth = inject(AuthService);
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the auth API inside the browser. A real server signs the token
// with a secret key; this practice server only imitates the format.
const ACCOUNTS = [
  { name: 'Asha Rao', email: 'asha@shop.com', password: 'user123', role: 'user' },
  { name: 'Admin', email: 'admin@shop.com', password: 'admin123', role: 'admin' },
];

function makeToken(account: any, seconds: number) {
  const encode = (data: object) => btoa(JSON.stringify(data));
  const exp = Math.floor(Date.now() / 1000) + seconds;
  const payload = { name: account.name, email: account.email, role: account.role, exp };
  return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.practice-signature`;
}

function readToken(req: HttpRequest<any>) {
  const header = req.headers.get('Authorization') ?? '';
  try {
    const payload = JSON.parse(atob(header.replace('Bearer ', '').split('.')[1]));
    return payload.exp * 1000 > Date.now() ? payload : null;
  } catch {
    return null;
  }
}

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  const body = req.body ?? {};
  if (req.url === '/api/auth/register') {
    if (ACCOUNTS.some(a => a.email === body.email)) return fail(409, 'This email is already registered.');
    const account = { name: body.name, email: body.email, password: body.password, role: 'user' };
    ACCOUNTS.push(account);
    return reply({ token: makeToken(account, 900) });
  }
  if (req.url === '/api/auth/login') {
    const account = ACCOUNTS.find(a => a.email === body.email && a.password === body.password);
    return account ? reply({ token: makeToken(account, body.seconds ?? 900) }) : fail(401, 'Wrong email or password.');
  }
  const user = readToken(req);
  if (!user) return fail(401, 'Please log in.');
  if (req.url === '/api/me') return reply(user);
  if (req.url === '/api/admin/report') {
    return user.role === 'admin' ? reply({ sales: 12400, orders: 310 }) : fail(403, 'Administrators only.');
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(200));
}

function fail(status: number, message: string) {
  return timer(200).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Click the button and wait 5 seconds. The app logs out and shows a message.

User Roles

A role is a group of permissions, for example user or admin. The server puts the role into the token payload, so the app can read it from auth.user().role.

readonly isAdmin = computed(() => this.user()?.role === 'admin');

Role-Based Access Control

Role-Based Access Control (RBAC) means that the role decides what a user may do. In the app, you use the role in two places:

  • In templates: show a link or button only for allowed roles, for example @if (auth.isAdmin()) { <a routerLink="/admin">Admin</a> }.
  • In routes: block a page for other roles with a route guard. You learn guards in the next section.
FeaturePublic (guest)useradmin
Home page✅✅✅
My profile❌✅✅
Admin reports❌❌✅

Hiding a button or blocking a route in Angular does not protect data. A user can change the code in their browser or call the API directly. The server must check the token and the role for every request. Angular checks are only for a good user experience.

Route Guards

A route guard is a function that the router calls before it opens a route. It returns:

routerLink="/profile"user clicksauthGuardlogged in?true → Profilethe page opensUrlTree → /loginredirected

A guard decides before the route opens.

  • true: the route opens.
  • A UrlTree: the router goes to that URL instead. Create it with router.createUrlTree(['/login']).
  • false: the navigation is cancelled.

A guard of type CanActivateFn is added to a route with canActivate:

export const authGuard: CanActivateFn = () => {
  return inject(AuthService).isLoggedIn() ? true : inject(Router).createUrlTree(['/login']);
};

export const routes: Routes = [
  { path: 'profile', component: Profile, canActivate: [authGuard] },
  { path: 'admin', component: Admin, canActivate: [roleGuard], data: { roles: ['admin'] } },
];

data is an object with extra values for a route. The roleGuard reads the allowed roles from route.data['roles'].

Guards for logged-in users and admins

import { Component, Injectable, computed, inject, signal } from '@angular/core';
import { CanActivateFn, Router, RouterLink, RouterOutlet, Routes } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly role = signal<string | null>(null);
  readonly isLoggedIn = computed(() => this.role() !== null);
}

export const authGuard: CanActivateFn = () => {
  return inject(AuthService).isLoggedIn() ? true : inject(Router).createUrlTree(['/login']);
};

export const roleGuard: CanActivateFn = route => {
  const auth = inject(AuthService);
  const allowed: string[] = route.data['roles'] ?? [];
  if (allowed.includes(auth.role() ?? '')) {
    return true;
  }
  return inject(Router).createUrlTree(auth.isLoggedIn() ? ['/forbidden'] : ['/login']);
};

@Component({ selector: 'app-home', template: `<p>🏠 Home: public page.</p>` })
export class Home {}

@Component({ selector: 'app-login', template: `<p>🔑 Please log in.</p>` })
export class Login {}

@Component({ selector: 'app-profile', template: `<p>👤 My profile.</p>` })
export class Profile {}

@Component({ selector: 'app-admin', template: `<p>🛠 Admin reports.</p>` })
export class Admin {}

@Component({ selector: 'app-forbidden', template: `<p>⛔ 403: you may not open this page.</p>` })
export class Forbidden {}

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'login', component: Login },
  { path: 'profile', component: Profile, canActivate: [authGuard] },
  { path: 'admin', component: Admin, canActivate: [roleGuard], data: { roles: ['admin'] } },
  { path: 'forbidden', component: Forbidden },
];

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink],
  template: `
    <p>
      Role: <b>{{ auth.role() ?? 'guest' }}</b>
      <button (click)="auth.role.set('user')">Be user</button>
      <button (click)="auth.role.set('admin')">Be admin</button>
      <button (click)="auth.role.set(null)">Log out</button>
    </p>
    <a routerLink="/">Home</a> | <a routerLink="/profile">Profile</a> | <a routerLink="/admin">Admin</a>
    <router-outlet />
  `,
})
export class App {
  auth = inject(AuthService);
}

As a guest, click Profile: the guard sends you to the login page. As a user, click Admin: you get the 403 page. As an admin, every link opens.

Authentication Errors

Show clear messages for authentication problems. Never show raw technical errors.

StatusSituationWhat the app does
400The form data is incompleteShow the server's message next to the form
401 at loginWrong email or passwordShow "Wrong email or password." Do not say which one was wrong
401 laterThe token expired or is invalidLog out and show "Your session has expired"
403The user's role is not allowedShow an "Access denied" page
409 at registrationThe email already existsShow "This email is already registered."
0The server cannot be reachedShow "Could not reach the server."

The interceptor is a good place to handle 401 for every request:

return next(request).pipe(
  catchError((error: HttpErrorResponse) => {
    if (error.status === 401 && token) {
      auth.logout('Your session has ended. Please log in again.');
    }
    return throwError(() => error); // the component can still show its own message
  }),
);

Logout

Logout ends the session in the app:

  1. Stop the expiration timer.
  2. Set the token signal to null. Every component that reads the state updates.
  3. Remove the saved token from localStorage.
  4. Navigate to the login or home page.
logout(message = '') {
  clearTimeout(this.expiryTimer);
  this._token.set(null);
  localStorage.removeItem('auth_token');
  this.message.set(message);
  this.router.navigate(['/login']);
}

A JWT stays valid on the server until it expires, even after logout. Use short expiration times, such as 15 minutes, so a copied token stops working soon.

Capstone Project

You now combine everything in one app.

Registration and Login

Two forms with ngModel call auth.register() and auth.login(). Errors from the server are shown under the form.

JWT Authentication

AuthService stores the token in a signal. user, isLoggedIn and isAdmin are computed from the payload.

Public Access

The home page has no guard. Everybody can open it.

User Permissions

The profile page uses authGuard, and it requests /api/me. The interceptor adds the token.

Administrator Permissions

The admin page uses roleGuard with data: { roles: ['admin'] }. The server also checks the role and answers 403 for other users.

Protected Routes

A guest who opens a protected route is sent to the login page. A user without the admin role is sent to the "Access denied" page.

Token Expiration

The Log in form has a "Short session" checkbox. It creates a 10-second token, so you can see the automatic logout.

Logout

Log out in the header clears the state and opens the login page.

Capstone: User Access Control

import { ApplicationConfig, Component, Injectable, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  HttpClient, HttpErrorResponse, HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpResponse,
  provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { CanActivateFn, Router, RouterLink, RouterOutlet, Routes, provideRouter } from '@angular/router';
import { catchError, delay, mergeMap, of, tap, throwError, timer } from 'rxjs';

// ── Auth service ──
function decodeToken(token: string) {
  return JSON.parse(atob(token.split('.')[1]));
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private router = inject(Router);
  private expiryTimer: any;
  private readonly _token = signal<string | null>(null);

  readonly token = this._token.asReadonly();
  readonly user = computed(() => {
    const token = this._token();
    return token ? decodeToken(token) : null;
  });
  readonly isLoggedIn = computed(() => this.user() !== null);
  readonly isAdmin = computed(() => this.user()?.role === 'admin');
  readonly message = signal('');

  register(name: string, email: string, password: string) {
    return this.http
      .post<{ token: string }>('/api/auth/register', { name, email, password })
      .pipe(tap(res => this.setToken(res.token)));
  }

  login(email: string, password: string, seconds = 900) {
    return this.http
      .post<{ token: string }>('/api/auth/login', { email, password, seconds })
      .pipe(tap(res => this.setToken(res.token)));
  }

  logout(message = '') {
    clearTimeout(this.expiryTimer);
    this._token.set(null);
    this.message.set(message);
    this.router.navigate(['/login']);
  }

  private setToken(token: string) {
    this._token.set(token);
    this.message.set('');
    clearTimeout(this.expiryTimer);
    const msLeft = decodeToken(token).exp * 1000 - Date.now();
    this.expiryTimer = setTimeout(() => this.logout('Your session has expired. Please log in again.'), msLeft);
  }
}

// ── Interceptor ──
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.token();
  const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
  return next(request).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401 && token) {
        auth.logout('Your session has ended. Please log in again.');
      }
      return throwError(() => error);
    }),
  );
};

// ── Guards ──
export const authGuard: CanActivateFn = () => {
  return inject(AuthService).isLoggedIn() ? true : inject(Router).createUrlTree(['/login']);
};

export const roleGuard: CanActivateFn = route => {
  const auth = inject(AuthService);
  const allowed: string[] = route.data['roles'] ?? [];
  if (allowed.includes(auth.user()?.role)) {
    return true;
  }
  return inject(Router).createUrlTree(auth.isLoggedIn() ? ['/forbidden'] : ['/login']);
};

// ── Pages ──
@Component({ selector: 'app-home', template: `<h4>Home</h4><p>Public page. Everybody can see it.</p>` })
export class Home {}

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  template: `
    <h4>Log in</h4>
    @if (auth.message()) {
      <p>ℹ {{ auth.message() }}</p>
    }
    <form (ngSubmit)="submit()">
      <p><input name="email" [(ngModel)]="email" placeholder="Email" /></p>
      <p><input name="password" type="password" [(ngModel)]="password" placeholder="Password" /></p>
      <p><label><input type="checkbox" name="short" [(ngModel)]="shortSession" /> Short session (10 seconds)</label></p>
      <button type="submit">Log in</button>
    </form>
    @if (error()) {
      <p>⚠ {{ error() }}</p>
    }
    <p>Try asha&#64;shop.com / user123 or admin&#64;shop.com / admin123</p>
  `,
})
export class Login {
  auth = inject(AuthService);
  private router = inject(Router);
  email = 'asha@shop.com';
  password = 'user123';
  shortSession = false;
  error = signal('');

  submit() {
    this.error.set('');
    this.auth.login(this.email, this.password, this.shortSession ? 10 : 900).subscribe({
      next: () => this.router.navigate(['/profile']),
      error: (err: HttpErrorResponse) => this.error.set(err.error.message),
    });
  }
}

@Component({
  selector: 'app-register',
  imports: [FormsModule],
  template: `
    <h4>Register</h4>
    <form (ngSubmit)="submit()">
      <p><input name="name" [(ngModel)]="name" placeholder="Name" /></p>
      <p><input name="email" [(ngModel)]="email" placeholder="Email" /></p>
      <p><input name="password" type="password" [(ngModel)]="password" placeholder="Password" /></p>
      <button type="submit">Create account</button>
    </form>
    @if (error()) {
      <p>⚠ {{ error() }}</p>
    }
  `,
})
export class Register {
  private auth = inject(AuthService);
  private router = inject(Router);
  name = '';
  email = '';
  password = '';
  error = signal('');

  submit() {
    if (!this.name || !this.email || this.password.length < 6) {
      this.error.set('Enter a name, an email and a password of at least 6 characters.');
      return;
    }
    this.auth.register(this.name, this.email, this.password).subscribe({
      next: () => this.router.navigate(['/profile']),
      error: (err: HttpErrorResponse) => this.error.set(err.error.message),
    });
  }
}

@Component({
  selector: 'app-profile',
  template: `
    <h4>My profile</h4>
    @if (me(); as me) {
      <p>{{ me.name }} ({{ me.email }}), role: {{ me.role }}</p>
    }
  `,
})
export class Profile {
  me = signal<any>(null);
  constructor() {
    inject(HttpClient).get<any>('/api/me').subscribe(data => this.me.set(data));
  }
}

@Component({
  selector: 'app-admin',
  template: `
    <h4>Admin reports</h4>
    @if (report(); as report) {
      <p>Sales: {{ report.sales }}, orders: {{ report.orders }}</p>
    }
  `,
})
export class Admin {
  report = signal<any>(null);
  constructor() {
    inject(HttpClient).get<any>('/api/admin/report').subscribe(data => this.report.set(data));
  }
}

@Component({ selector: 'app-forbidden', template: `<h4>Access denied</h4><p>You may not open this page.</p>` })
export class Forbidden {}

// ── Routes and configuration ──
const routes: Routes = [
  { path: '', component: Home },
  { path: 'login', component: Login },
  { path: 'register', component: Register },
  { path: 'profile', component: Profile, canActivate: [authGuard] },
  { path: 'admin', component: Admin, canActivate: [roleGuard], data: { roles: ['admin'] } },
  { path: 'forbidden', component: Forbidden },
];

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor, practiceServer]))],
};

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink],
  template: `
    <nav>
      <a routerLink="/">Home</a> |
      <a routerLink="/profile">Profile</a> |
      <a routerLink="/admin">Admin</a> |
      @if (auth.isLoggedIn()) {
        {{ auth.user()?.name }} ({{ auth.user()?.role }})
        <button (click)="auth.logout()">Log out</button>
      } @else {
        <a routerLink="/login">Log in</a> | <a routerLink="/register">Register</a>
      }
    </nav>
    <router-outlet />
  `,
})
export class App {
  auth = inject(AuthService);
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the auth API inside the browser. A real server signs the token
// with a secret key; this practice server only imitates the format.
const ACCOUNTS = [
  { name: 'Asha Rao', email: 'asha@shop.com', password: 'user123', role: 'user' },
  { name: 'Admin', email: 'admin@shop.com', password: 'admin123', role: 'admin' },
];

function makeToken(account: any, seconds: number) {
  const encode = (data: object) => btoa(JSON.stringify(data));
  const exp = Math.floor(Date.now() / 1000) + seconds;
  const payload = { name: account.name, email: account.email, role: account.role, exp };
  return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.practice-signature`;
}

function readToken(req: HttpRequest<any>) {
  const header = req.headers.get('Authorization') ?? '';
  try {
    const payload = JSON.parse(atob(header.replace('Bearer ', '').split('.')[1]));
    return payload.exp * 1000 > Date.now() ? payload : null;
  } catch {
    return null;
  }
}

function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
  const body = req.body ?? {};
  if (req.url === '/api/auth/register') {
    if (ACCOUNTS.some(a => a.email === body.email)) return fail(409, 'This email is already registered.');
    const account = { name: body.name, email: body.email, password: body.password, role: 'user' };
    ACCOUNTS.push(account);
    return reply({ token: makeToken(account, 900) });
  }
  if (req.url === '/api/auth/login') {
    const account = ACCOUNTS.find(a => a.email === body.email && a.password === body.password);
    return account ? reply({ token: makeToken(account, body.seconds ?? 900) }) : fail(401, 'Wrong email or password.');
  }
  const user = readToken(req);
  if (!user) return fail(401, 'Please log in.');
  if (req.url === '/api/me') return reply(user);
  if (req.url === '/api/admin/report') {
    return user.role === 'admin' ? reply({ sales: 12400, orders: 310 }) : fail(403, 'Administrators only.');
  }
  return fail(404, 'Not Found');
}

function reply(body: unknown) {
  return of(new HttpResponse({ status: 200, body })).pipe(delay(200));
}

function fail(status: number, message: string) {
  return timer(200).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, error: { message } }))));
}

Try these steps: open Profile as a guest, log in as Asha, open Admin, log out, log in as the admin, register a new account, and log in with a short session.

Download Project Files

Download Authentication and Authorization Project Source Code

Download angular-auth-portal.zip

The ZIP file contains the capstone as a complete Angular CLI project with a real API server. The server signs real JWTs with a secret key, stores password hashes, and checks the token and the role for every protected request.

FileWhat it contains
server/server.jsThe API: register, login, /api/me and /api/admin/users, with JWT signing
src/app/auth/auth.service.tsAuthentication state, login, registration, logout, localStorage and expiration
src/app/auth/auth.interceptor.tsAdds the token and logs out on 401
src/app/auth/auth.guards.tsauthGuard, roleGuard and guestGuard
src/app/pages/Home, login, register, profile, admin and "access denied" pages
src/environments/The API address

Project Setup Instructions

You need Node.js 20.19 or newer.

  1. Unzip the file and open a terminal in the auth-portal folder.
  2. Install the packages:

    npm install
  3. Start the API server on http://localhost:3000. Keep this terminal open:

    npm run api
  4. In a second terminal, start the Angular app:

    npm start
  5. Open http://localhost:4200 and log in with one of the test accounts:

    EmailPasswordRole
    admin@example.comAdmin123!admin
    asha@example.comUser123!user

To test token expiration, stop the API and start it with npm run api:short. Tokens then expire after 30 seconds.

Common Mistakes

MistakeWhat you seeFix
Protecting data only with Angular guardsUsers can still call the API directly and get the dataCheck the token and role on the server for every request
Secret data in the JWT payloadAnyone with the token can read itPut only an ID, name and role in the payload
Adding the token by hand in each serviceSome requests forget it and get 401Use one interceptor
Ignoring expThe app looks logged in, but every request failsLog out with a timer, and on every 401
Logout that only clears the signalAfter a reload, the user is logged in againAlso remove the token from localStorage
Login error says "Wrong password"Attackers learn which emails existSay "Wrong email or password."

Check Yourself

Exercises

What to Remember

  • Authentication checks who the user is; authorization checks what they may do. After login, the server sends a signed JWT, and the app sends it back in the Authorization header with an interceptor.
  • Keep the token and the values computed from it in an AuthService. Save it to survive reloads, log out when it expires or the server answers 401, and clear everything on logout.
  • Use roles and route guards (CanActivateFn returning true or a UrlTree) for the user experience, and always check permissions again on the server.

Next: Error Handling, how to find, handle and log errors in an Angular app.