Skip to main content

Protecting routes

authGuard is a functional CanActivateFn. Add it to canActivate and, when a route needs more than a signed-in user, declare the role requirement in data.auth.

src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard, AuthRouteData } from '@ismailza/ngx-auth-client';

export const routes: Routes = [
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard').then((m) => m.Dashboard),
},
{
path: 'admin',
canActivate: [authGuard],
data: { auth: { anyOf: ['admin', 'owner'] } } satisfies AuthRouteData,
loadComponent: () => import('./admin').then((m) => m.Admin),
},
];

What the guard decides

Three things are worth drawing out.

It awaits readiness first. A guard that ran before the session was restored would bounce signed-in users to the login page on every hard refresh. await auth.whenReady() makes the decision against settled state.

Unauthenticated users come back where they started. The guard calls login() with the requested URL as the redirect target, so the round-trip through the identity provider is invisible:

await auth.login({ redirectUri: `${globalThis.location.origin}${state.url}` });

Missing roles are a different outcome from missing authentication. Signing in again wouldn't grant a role, so those users go to forbiddenRoute rather than to the login page.

Role requirements

data.auth accepts two fields:

FieldMeaningSatisfied when
anyOfThe user must hold at least oneAny listed role is held
allOfThe user must hold every oneAll listed roles are held
// at least one of
data: { auth: { anyOf: ['admin', 'owner'] } } satisfies AuthRouteData;

// all of
data: { auth: { allOf: ['admin', 'finance'] } } satisfies AuthRouteData;

// both conditions apply
data: { auth: { anyOf: ['admin', 'owner'], allOf: ['mfa-verified'] } } satisfies AuthRouteData;

Omit data.auth to require authentication only.

Use satisfies AuthRouteData

Route['data'] is typed as Record<string, any>, so a typo like { auth: { anyof: [...] } } compiles happily and silently protects nothing. satisfies AuthRouteData gets you the type check without widening the property's type.

Requirements accumulate down the tree

The guard walks from the root route to the one being activated and collects every data.auth it finds. A requirement on a parent route applies to all of its children — so a protected section is configured once, at its root.

export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard],
data: { auth: { anyOf: ['admin', 'owner'] } } satisfies AuthRouteData,
children: [
// requires: anyOf ['admin', 'owner']
{ path: 'users', loadComponent: () => import('./users').then((m) => m.Users) },

// requires: anyOf ['admin', 'owner'] AND allOf ['finance']
{
path: 'billing',
data: { auth: { allOf: ['finance'] } } satisfies AuthRouteData,
loadComponent: () => import('./billing').then((m) => m.Billing),
},
],
},
];

Note that canActivate sits on the parent only. Angular runs a parent's guard for any child activation, so the whole subtree is covered.

Two anyOf groups are two conditions

Each anyOf stays a separate group rather than being merged into one list. Two ancestors each demanding "one of these" are two independent conditions:

{
path: 'reports',
data: { auth: { anyOf: ['staff', 'contractor'] } } satisfies AuthRouteData,
children: [
{
path: 'financial',
data: { auth: { anyOf: ['finance', 'exec'] } } satisfies AuthRouteData,
// …
},
],
}

Reaching reports/financial requires one role from each group. A user with only staff is refused. Flattening the two into ['staff', 'contractor', 'finance', 'exec'] would let a lone staff role satisfy both — which is not what either route asked for.

allOf sets, by contrast, are simply unioned: every role in every allOf along the path must be held.

A guard stops navigation; it doesn't stop a dead link from appearing in your menu. Pair it with a role check in the template:

@Component({
template: `
@if (canAdminister()) {
<a routerLink="/admin">Admin</a>
}
`,
})
export class Nav {
private readonly auth = inject(AuthService);
protected readonly canAdminister = computed(() => this.auth.hasAnyRole(['admin', 'owner']));
}

The requirement is now written twice, which is worth avoiding for anything non-trivial — export it from one place:

src/app/auth-requirements.ts
export const ADMIN_AREA = { anyOf: ['admin', 'owner'] } as const;
data: { auth: ADMIN_AREA } satisfies AuthRouteData;
protected readonly canAdminister = computed(() =>
this.auth.hasAnyRole(ADMIN_AREA.anyOf),
);

Guarding lazy-loaded routes

authGuard returns a promise, and Angular awaits it before loading the route's bundle. A user without the required roles never downloads the chunk:

{
path: 'admin',
canActivate: [authGuard],
data: { auth: { anyOf: ['admin'] } } satisfies AuthRouteData,
loadChildren: () => import('./admin/routes').then((m) => m.routes),
}

This is a bundle-size benefit, not a security one — the chunk is still served by your web server to anyone who requests its URL directly.

Child routes and canActivateChild

authGuard is a CanActivateFn. Angular's canActivateChild expects the same signature, so it can be used there too:

{
path: 'admin',
canActivateChild: [authGuard],
data: { auth: { anyOf: ['admin'] } } satisfies AuthRouteData,
children: [ /* … */ ],
}

Use canActivate when the parent route renders something itself, and canActivateChild when it's a pathless or purely structural route whose children are the real destinations.

On the server, the guard refuses

Under SSR the guard returns false without attempting a redirect. Tokens live in memory only, so the server has no session to evaluate — and rendering protected content into a response that might be cached would leak it.

Protected routes therefore render nothing server-side and resolve on the client. See Server-side rendering.

Testing guarded routes

Use the fake adapter and a real router:

TestBed.configureTestingModule({
providers: [
provideAuth(withFakeAuth({ authenticated: true, roles: ['admin'] })),
provideRouter(routes),
],
});

const router = TestBed.inject(Router);
expect(await router.navigate(['/admin'])).toBe(true);

See Testing for asserting the redirect and login paths.

Next