Authentication State in Angular: Functional Guards, Interceptors, Single-Flight Token Refresh and UI Authorization
Guards and hidden buttons in Angular are user experience, not a security boundary; the real decision is made in the API. We write a signal-based auth state service, canMatch guards, a permission directive, an interceptor that ties concurrent 401s to a single refresh, and cross-tab sign-out with BroadcastChannel, test them all with Vitest, and show that a request bypassing the guard gets a 403 from the API.
Version note: The code in this post was built and run with Angular 22.2, Vitest 5.1, .NET 10 and Keycloak 26.8.2.
Guess First
In Bookstore's Angular app, the "Refunds" page is visible only to users with the orders.refund permission; the menu link is hidden and a guard protects the route. What happens if Ada opens the browser's DevTools and disables the guard? Second question: the access token has expired and the page made three API (Application Programming Interface) calls at once; all three got a 401. How many times should the app try to refresh? Third: Ada is open in two tabs and signs out of one. When does the other tab notice?
In this post we finish the browser side. In post 14 we put Angular behind a BFF (Backend for Frontend); it sees no tokens, only a cookie and the X-CSRF header. But Angular still has to answer "who signed in, and what can I show?". For apps that must keep tokens in the browser, we will also write the hardest piece: a single refresh for concurrent 401s. The basics of Angular's HttpClient, interceptors and guards are covered in the site's Angular posts; here we focus on the auth-specific parts. All the code's tests run with Vitest.
Two Scenarios, One Rule
In the BFF scenario Angular's auth state is made of two calls: /bff/user says who the user is, and /api/me/permissions, which we added to the API in post 16, says what they can do. In the token scenario (where Angular is a public client, the architecture RFC (Request for Comments) 10017 ranks third), the access token sits in memory and must be renewed when it expires. One rule holds in both: every authorization decision in Angular is for user experience; the security decision is made in the API.
Signal-Based Auth State
export interface BffUser {
sub: string;
name: string;
email: string;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'anonymous' }
| { status: 'authenticated'; user: BffUser; permissions: ReadonlySet<string> };
export type AuthMessage = 'signed-in' | 'signed-out';
Keeping the state as a three-valued discriminated union rules out the most common mistake at the type level: in an app that starts with isLoggedIn: boolean, the "don't know yet" state collapses into false, and on a page refresh the guard sends a signed-in user to login. In the unknown state guards must wait:
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly state = signal<AuthState>({ status: 'unknown' });
// Other tabs on the same origin learn about sign-in/sign-out
private readonly channel = new BroadcastChannel('bookstore-auth');
private loading: Promise<void> | null = null;
readonly status = computed(() => this.state().status);
readonly user = computed(() => {
const state = this.state();
return state.status === 'authenticated' ? state.user : null;
});
readonly permissions = computed(() => {
const state = this.state();
return state.status === 'authenticated' ? state.permissions : new Set<string>();
});
constructor() {
this.channel.onmessage = (event: MessageEvent<AuthMessage>) => {
if (event.data === 'signed-out') {
this.state.set({ status: 'anonymous' });
} else {
this.loading = null;
void this.ensureLoaded();
}
};
inject(DestroyRef).onDestroy(() => this.channel.close());
}
hasPermission(permission: string): boolean {
return this.permissions().has(permission);
}
// Even if guards call it at the same time, /bff/user is requested once
ensureLoaded(): Promise<void> {
this.loading ??= this.load();
return this.loading;
}
login(returnUrl: string): void {
window.location.assign(`/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`);
}
async logout(): Promise<void> {
const { logoutUrl } = await firstValueFrom(this.http.post<{ logoutUrl: string }>('/bff/logout', null));
this.state.set({ status: 'anonymous' });
this.channel.postMessage('signed-out' satisfies AuthMessage);
window.location.assign(logoutUrl);
}
private async load(): Promise<void> {
try {
const user = await firstValueFrom(this.http.get<BffUser>('/bff/user'));
const permissions = await firstValueFrom(this.http.get<string[]>('/api/me/permissions'));
this.state.set({ status: 'authenticated', user, permissions: new Set(permissions) });
} catch {
this.state.set({ status: 'anonymous' });
}
}
}
Only computed signals are exposed; the only place that can change the state is the service itself. ensureLoaded shares the same Promise: even if three guards and a component ask at once when the page opens, a single request goes to /bff/user. Login is not an XHR (XMLHttpRequest) but a full-page navigation; we saw why in post 14. Permissions are not computed in Angular but come from the API; the role → permission mapping stays in one place, on the server.
The unknown state helps in the template too. The home page's @switch renders nothing for this state; so on a page refresh a signed-in user doesn't see a "Sign in" button for a moment and then watch their name appear. For a long load, a skeleton view is better here; but making the "not signed in" view the default makes users think their session dropped and leads to needless sign-in attempts.
Functional Guards: CanMatch and CanActivate
// If not signed in, the route never matches and the user goes to the BFF's login
export const authGuard: CanMatchFn = async (_route, segments) => {
const auth = inject(AuthService);
await auth.ensureLoaded();
if (auth.status() === 'authenticated') {
return true;
}
auth.login('/' + segments.map(s => s.path).join('/'));
return false;
};
// Without the permission, go to the 403 page; a UX decision, the real check is in the API
export const permissionGuard = (permission: string): CanMatchFn => async () => {
const auth = inject(AuthService);
const router = inject(Router);
await auth.ensureLoaded();
return auth.hasPermission(permission) ? true : router.parseUrl('/forbidden');
};
export const routes: Routes = [
{ path: '', component: HomePage },
{ path: 'orders', component: OrdersPage, canMatch: [authGuard] },
{ path: 'refunds', component: RefundsPage, canMatch: [authGuard, permissionGuard('orders.refund')] },
{ path: 'forbidden', component: ForbiddenPage }
];
I preferred canMatch over canActivate. canActivate runs after the route has matched; canMatch decides whether the route matches at all. On a lazy-loaded route the difference matters: if canMatch returns false, that route's JavaScript chunk is never downloaded, and the admin panel's code never reaches an unauthorized user's browser. There is flexibility too: if canMatch rejects, the router tries the next route for the same path, so you can bind a different component per role to the same address.
In Angular 22 the CanMatchFn signature takes three parameters; when I first wrote the test I called it with two arguments and the compiler stopped me:
✘ [ERROR] TS2554: Expected 3 arguments, but got 2. [plugin angular-compiler]
An argument for 'currentSnapshot' was not provided.
node_modules/@angular/router/types/_router_module-chunk.d.ts:3108:57:
3108 │ ...egment[], currentSnapshot: PartialMatchRouteSnapshot) => Mayb...
The third parameter is PartialMatchRouteSnapshot: the fields known at match time, such as params, queryParams and data. My guards don't use it, so it is absent from their definitions, and the test passes an empty object:
const snapshot = {} as PartialMatchRouteSnapshot;
it('authGuard sends an anonymous user to the BFF login with the return URL', async () => {
status.set('anonymous');
const result = await TestBed.runInInjectionContext(() =>
authGuard({}, [new UrlSegment('orders', {})], snapshot));
expect(result).toBe(false);
expect(login).toHaveBeenCalledWith('/orders');
});
it('permissionGuard redirects to /forbidden without the permission', async () => {
status.set('authenticated');
const result = await TestBed.runInInjectionContext(() => permissionGuard('orders.refund')({}, [], snapshot));
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/forbidden');
permissions.add('orders.refund');
expect(await TestBed.runInInjectionContext(() => permissionGuard('orders.refund')({}, [], snapshot))).toBe(true);
});
UI Authorization: A Permission Directive
// <button *appHasPermission="'orders.refund'">: visibility only; the API still checks
@Directive({ selector: '[appHasPermission]' })
export class HasPermissionDirective {
private readonly auth = inject(AuthService);
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
readonly appHasPermission = input.required<string>();
constructor() {
effect(() => {
this.container.clear();
if (this.auth.hasPermission(this.appHasPermission())) {
this.container.createEmbeddedView(this.template);
}
});
}
}
<a *appHasPermission="'orders.refund'" routerLink="/refunds">Refunds</a>
The directive reads two signals inside an effect: the input and the auth state's permissions. When either changes, the view is rebuilt; a signed-out user's button disappears instantly, even from a signed-out message arriving from another tab. Its test changes the signal and checks the DOM (Document Object Model):
it('shows the element only while the permission is present', () => {
const permissions = signal(new Set<string>());
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { hasPermission: (p: string) => permissions().has(p) } }]
});
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button')).toBeNull();
permissions.set(new Set(['orders.refund']));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button')?.textContent).toBe('Refund');
});
Between hiding a button and making it disabled there is a UX (User Experience) choice: hiding keeps the screen clean; disabling it with an explanation tells the user "this exists, but you lack the permission". For a support team the second often means fewer support tickets.
Interceptors: A Header with the BFF, Single-Flight Refresh with Tokens
The interceptor in the BFF scenario is the one-line csrfHeaderInterceptor from post 14; Angular has nothing to do with refresh, the BFF handles it. In the token scenario, though, the API returns 401 when the access token expires, and the app has to refresh and repeat the request. Written naively, an interceptor triggers a separate refresh for every 401: three requests at page load, three refreshes. If refresh token rotation is on (post 09), the second refresh sends a refresh token already consumed by the first; the server sees reuse, revokes the whole token family, and the user is thrown out of the session. The fix is to share the refresh in a single flight:
// For the scenario without a BFF (tokens in the browser): the access token lives only in memory
@Injectable({ providedIn: 'root' })
export class TokenRefresher {
// HttpBackend: the refresh request bypasses the interceptor chain, so no loop
private readonly http = new HttpClient(inject(HttpBackend));
private inFlight: Observable<string> | null = null;
accessToken: string | null = null;
refreshCount = 0;
refresh(): Observable<string> {
this.inFlight ??= this.http.post<{ accessToken: string }>('/auth/refresh', null, { withCredentials: true }).pipe(
map(response => response.accessToken),
tap(token => {
this.accessToken = token;
this.refreshCount++;
}),
finalize(() => (this.inFlight = null)),
shareReplay(1)
);
return this.inFlight;
}
}
const RETRIED = new HttpContextToken<boolean>(() => false);
const withToken = (req: HttpRequest<unknown>, token: string | null) =>
token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
export const tokenRefreshInterceptor: HttpInterceptorFn = (req, next) => {
const tokens = inject(TokenRefresher);
const sentWith = tokens.accessToken;
return next(withToken(req, sentWith)).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse) || error.status !== 401 || req.context.get(RETRIED)) {
return throwError(() => error);
}
const retry = (token: string) => next(withToken(req.clone({ context: req.context.set(RETRIED, true) }), token));
// If the token was already renewed after this request was sent, don't refresh again
if (tokens.accessToken !== null && tokens.accessToken !== sentWith) {
return retry(tokens.accessToken);
}
return tokens.refresh().pipe(switchMap(retry));
})
);
};
There are four details. shareReplay(1) makes every concurrent 401 subscribe to the same refresh request; finalize makes room for the next refresh once it is done. The sentWith check catches late 401s: if a 401 for a request sent with the old token arrives after the refresh has finished, it is retried with the new token instead of starting a new refresh. The RETRIED context token breaks infinite loops: a request that gets a 401 even with the renewed token is not retried again. The refresh request goes through HttpBackend, so it stays outside the interceptor chain. In this scenario, where you keep the refresh token (HttpOnly cookie, memory) is a separate threat model discussion; it is the subject of post 05 of the upcoming "Frontend Security" series.
The test builds exactly the situation from Guess First:
it('refreshes once for three concurrent 401s and retries all of them', () => {
const results: unknown[] = [];
for (const url of ['/api/books', '/api/orders/mine', '/api/me/permissions']) {
http.get(url).subscribe(body => results.push(body));
}
for (const req of backend.match(r => r.url.startsWith('/api/'))) {
expect(req.request.headers.get('Authorization')).toBe('Bearer old');
req.flush(null, { status: 401, statusText: 'Unauthorized' });
}
backend.expectOne('/auth/refresh').flush({ accessToken: 'new' });
const retried = backend.match(r => r.url.startsWith('/api/'));
expect(retried.length).toBe(3);
for (const req of retried) {
expect(req.request.headers.get('Authorization')).toBe('Bearer new');
req.flush({ ok: true });
}
expect(results.length).toBe(3);
expect(tokens.refreshCount).toBe(1);
backend.verify();
});
$ npx ng test --watch=false --reporters=verbose
✓ bookstore-web src/app/auth/csrf.interceptor.spec.ts > csrfHeaderInterceptor > adds X-CSRF to same-origin calls 7ms
✓ bookstore-web src/app/auth/csrf.interceptor.spec.ts > csrfHeaderInterceptor > does not leak the header to other origins 1ms
✓ bookstore-web src/app/auth/token-refresh.interceptor.spec.ts > tokenRefreshInterceptor > refreshes once for three concurrent 401s and retries all of them 8ms
✓ bookstore-web src/app/auth/token-refresh.interceptor.spec.ts > tokenRefreshInterceptor > gives up after one retry so a broken token cannot loop 1ms
✓ bookstore-web src/app/auth/has-permission.directive.spec.ts > HasPermissionDirective > shows the element only while the permission is present 14ms
✓ bookstore-web src/app/auth/auth.guards.spec.ts > auth guards > authGuard sends an anonymous user to the BFF login with the return URL 7ms
✓ bookstore-web src/app/auth/auth.guards.spec.ts > auth guards > permissionGuard redirects to /forbidden without the permission 2ms
Test Files 4 passed (4)
Tests 7 passed (7)
Syncing Across Tabs: BroadcastChannel
Ada is open in two tabs and signs out of one. In the BFF scenario the cookie is already gone; the other tab will get a 401 on its next API call. But until that call, Ada's name, her orders and the "Refund" button stay on screen; on a shared computer that is a leak. BroadcastChannel carries messages between tabs on the same origin. AuthService sends signed-out on sign-out, the services in the other tabs set the state to anonymous, and everything bound to the signals (directive, header, guard) updates at once. The message reaches only tabs in the same browser profile; ending a session on another device is the job of the back-channel logout from post 12.
The answer to the third question: with BroadcastChannel, immediately; without it, on the next API call. If the tab sits in the background without making any call, without it, never.
The Real Check Is in the API
Disabling the guard takes seconds in a browser: a breakpoint in DevTools, or a direct request to the API. I tried it in my own lab, with Ada's session. In the API the refund endpoint is tied to a permission:
// The decision is made here, whether or not the UI hides the button
app.MapPost("/orders/{id:int}/refund", (int id) => Results.Ok(new { Order = id, Status = "refund-requested" }))
.RequireAuthorization(policy => policy.RequirePermission(Permissions.OrdersRefund));
public static AuthorizationPolicyBuilder RequirePermission(this AuthorizationPolicyBuilder policy, string permission) =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
RolePermissions.For(RolePermissions.KeycloakRealmRoles(context.User)).Contains(permission));
$ for u in ada alan; do
J=/tmp/authwork2-c14-$u; sh login14.sh $u $J > /dev/null
echo "$u: $(curl -sk -b $J -H 'X-CSRF: 1' -X POST -o /tmp/r.txt -w '%{http_code}' https://localhost:19214/api/orders/1001/refund) $(cat /tmp/r.txt)"
done
ada: 403
alan: 200 {"order":1001,"status":"refund-requested"}
That is the answer to the first question: if Ada gets past the guard she sees an empty page, and if she manages to press the refund button the API returns 403. Guards and directives are not a security boundary; they are a UX layer that keeps users from starting work they cannot finish. The real refund rule must also check tenant, limit and time in the API with RefundPolicy from post 16.
In Production
The first load of the auth state adds to the app's startup time: /bff/user and /api/me/permissions are two round trips. You can combine them into one endpoint, or load them before the app starts with provideAppInitializer; in the second case the startup screen waits for this call, so think about timeouts and failures. A user's permissions can change during the session: don't cache the permission list forever; reload it on a 403 or at intervals.
Handle 401 and 403 separately: 401 is "I don't know who you are" (sign in or refresh), 403 is "I know you, but you're not allowed" (a permission message, not a new sign-in). An interceptor that redirects to login on every 403 puts the user into an endless sign-in loop. In the token scenario, remember that the refresh endpoint can fail too; if refresh returns 401, set the state to anonymous and send the user to login, don't retry.
Common Pitfalls
First, treating a guard or a hidden button as a security boundary. Second, keeping auth state as a boolean and losing the "not known yet" state. Third, a separate refresh for each concurrent 401; with rotation on, it throws the user out of the session. Fourth, sending the refresh request through the same interceptor chain and getting into a 401 loop.
Fifth, copying the role → permission mapping into Angular; a rule kept in two places will diverge sooner or later. Sixth, adding the CSRF (Cross-Site Request Forgery) header or the token to requests going to other origins. Seventh, clearing only the state on sign-out without going to the BFF and Keycloak.
A Small Experiment
★☆☆ When AuthService.load succeeds and the previous state was anonymous, send a signed-in message. Open two tabs, sign in on one, and watch the other update too.
★★☆ Write another test for tokenRefreshInterceptor: if the refresh request returns 401, all three pending requests should fail and refreshCount should not increase. Did it show that the code does this right, or that it needs a fix?
★★★ Make the Refunds page lazy-loaded (loadComponent), build for production, and show that when an unauthorized user opens the page the chunk is never downloaded with canMatch, and is downloaded with canActivate.
We have walked the series from the browser to the server and on to the authorization server. In the last post we look back: what changed in authentication and Identity from ASP.NET Core 6 to 11, which behaviors silently change when upgrading an old project, and what arrives with .NET 11.