Skip to main content

authGuard

A functional CanActivateFn that protects a route behind authentication and, optionally, role requirements declared in data.auth.

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

Return values

SituationReturnsEffect
Non-browser platformfalseRefuses, no redirect
UnauthenticatedfalseAfter calling login() with this route as the target
Authenticated, requirements satisfiedtrueActivates
Requirements unsatisfied, forbiddenRoute setUrlTreeRedirects there
Requirements unsatisfied, forbiddenRoute: nullfalseRefuses, stays put

The guard is async, so it returns Promise<boolean | UrlTree>. Angular awaits it before loading a lazy route's bundle.

Behaviour

It awaits AuthService.whenReady() before deciding, so it never evaluates a half-restored session. Without this, every hard refresh would bounce a signed-in user to the login page.

Unauthenticated users are returned to where they were going:

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

Missing roles are handled differently from missing authentication. Signing in again wouldn't grant a role, so those users go to forbiddenRoute instead of the login page.

On a non-browser platform it fails closed — refusing without a redirect. Tokens are memory-only so the server has no session to evaluate, and rendering protected content into a possibly-cached SSR response would leak it.

Use with canActivateChild

CanActivateFn and CanActivateChildFn share a signature, so the same guard works in either slot:

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

AuthRouteData

The route data contract the guard reads.

interface AuthRouteData {
auth?: RoleRequirement;
}

interface RoleRequirement {
anyOf?: readonly string[];
allOf?: readonly string[];
}
FieldMeaning
anyOfThe user must hold at least one
allOfThe user must hold every one

Both may be combined; both must then be satisfied. Omit auth to require authentication only.

Always write satisfies AuthRouteData

Route['data'] is Record<string, any>, so { auth: { anyof: [...] } } compiles and silently protects nothing. satisfies catches it without widening the property type.

Requirements accumulate

The guard walks route.pathFromRoot, so a requirement on a parent route applies to every child:

{
path: 'admin',
canActivate: [authGuard],
data: { auth: { anyOf: ['admin', 'owner'] } } satisfies AuthRouteData,
children: [
// inherits anyOf ['admin','owner'], adds allOf ['finance']
{
path: 'billing',
data: { auth: { allOf: ['finance'] } } satisfies AuthRouteData,
loadComponent: () => import('./billing').then((m) => m.Billing),
},
],
}

Reading only the leaf's data would silently drop a requirement declared on a section's parent — which is exactly where people declare it once. See Protecting routes.

Requirement helpers

Both are exported for custom guards and for testing. authGuard uses them internally.

collectRoleRequirements()

function collectRoleRequirements(route: ActivatedRouteSnapshot): AccumulatedRoleRequirements;

Walks from the root to route, collecting every data.auth.

interface AccumulatedRoleRequirements {
readonly anyOf: readonly (readonly string[])[];
readonly allOf: readonly string[];
}

Each anyOf group is kept separate rather than flattened, because two ancestors each demanding "one of these" are two independent conditions — merging them would let one role satisfy both. allOf sets are unioned and de-duplicated.

isSatisfiedBy()

function isSatisfiedBy(
requirements: AccumulatedRoleRequirements,
granted: readonly string[],
): boolean;

Whether granted satisfies every accumulated requirement: every role in allOf, and at least one role from each anyOf group.

Writing a custom guard

Compose the same pieces when you need extra conditions:

export const activeSubscriptionGuard: CanActivateFn = async (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);

await auth.whenReady();

if (!auth.authenticated()) {
await auth.login({ redirectUri: `${globalThis.location.origin}${state.url}` });
return false;
}

if (!isSatisfiedBy(collectRoleRequirements(route), auth.roles())) {
return router.parseUrl('/forbidden');
}

// The extra condition this guard exists for.
if (auth.claims()?.['subscription_status'] !== 'active') {
return router.parseUrl('/billing/renew');
}

return true;
};
Inject before the first await

Every inject() call must happen while the injection context is still active — that is, before the first await. Injecting afterwards throws NG0203.

Testing

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

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

See Testing.

Next