Authentication tells you who the user is. Authorization decides what the user should actually see.
Most Angular teams treat these as the same problem. In enterprise applications, they aren't β and conflating them is exactly where authorization logic starts to sprawl across components, routes, and services with no single source of truth.
This post isn't about installing Keycloak. It's about the architecture that shows up, again and again, in Angular teams that get authorization right: Keycloak stays responsible for authentication, and Angular Signals take over from there β distributing identity as reactive state across the entire application.
Table of Contents
- Authentication vs. Authorization
- The Core Architecture
- Keycloak Fundamentals: OIDC, OAuth2, JWT
- Roles, Realm Roles, Client Roles, Groups, Claims
- RBAC vs. PBAC
- The Signal-Based Auth Store
- The Permission Service β computed() Derivations
- Functional Guards: CanActivateFn & CanMatchFn
- Route Data & Declarative Route Security
- Conditional Rendering with @if and Structural Directives
- Permission & Role Directives
- Feature Flags on Top of Signals
- HttpInterceptorFn: Attaching Tokens and Handling 403s
- Silent Token Refresh with effect()
- Session Expiration & Logout Flow
- SSR Considerations
- Frontend Authorization vs. Backend Authorization (Zero Trust)
- Common Mistakes
- Best Practices Checklist
- Closing Thoughts
1. Authentication vs. Authorization
Authentication answers one question: who is this? Keycloak handles this end to end β the login form, the credential check, the multi-factor step, the session, the token issuance.
Authorization answers a different question: what is this person allowed to do, right now, in this exact part of the UI? That answer changes constantly β per route, per component, per button β and it needs to update the moment the underlying identity or role changes.
In enterprise Angular applications, the recurring failure mode isn't a broken login flow. It's authorization logic that got copy-pasted into a dozen components, drifted out of sync, and became nearly impossible to test as a whole. Signals give Angular a clean way to avoid that: expose authorization as reactive, derived state instead of scattered conditionals.
2. The Core Architecture
The pattern looks like this:
Keycloak β JWT β Signal Auth Store β Computed Permissions β Angular UI
Broken down:
- Keycloak authenticates the user and issues a JWT.
- The JWT is decoded once, and its claims populate a signal-based auth store.
- computed() signals derive roles, permissions, and feature access from that store.
- Components, functional guards, and HTTP interceptors all read from the same reactive source.
When a role changes β whether from a token refresh or an admin action β every part of the UI that depends on it updates automatically. No manual refresh. No re-fetch. No drift between what the UI shows and what the user is actually allowed to do.
Authentication Flow:
User β Keycloak β JWT β Signal Auth Store β Angular UI
Authorization Flow:
Role β Computed Permission β Route Guard β Component β Button Visibility
Permission Architecture:
Keycloak β Roles β Permission Service β Signals β Components β Routes β Menus
The golden rule worth repeating throughout this post: never scatter permission checks throughout your application. Centralize them and expose them reactively.
3. Keycloak Fundamentals: OIDC, OAuth2, JWT
A quick grounding before the Angular-specific parts:
- OAuth2 is an authorization framework β it defines how an application gets a token that represents a user's consent to access resources, without ever handling the user's password directly.
- OpenID Connect (OIDC) sits on top of OAuth2 and adds an identity layer β it's what actually tells your application who logged in, via an ID token alongside the access token.
- Keycloak is an identity provider that implements both. It runs the login UI, manages sessions, issues tokens, and exposes an admin console for managing realms, clients, roles, and users.
- A JWT (JSON Web Token) is the format Keycloak uses for access and ID tokens β a signed, self-contained payload of claims (header, payload, signature) that your backend can verify without a database round trip.
A JWT is not a security guarantee by itself. It's a signed claim. Your backend still has to verify the signature, check expiration, and validate that the claims actually authorize the requested action.
- Access Token β short-lived, sent with API requests, used by the backend to authorize calls.
- Refresh Token β longer-lived, used to silently obtain a new access token without forcing the user to log in again.
4. Roles, Realm Roles, Client Roles, Groups, Claims
Keycloak's authorization model has a few layers that map directly onto how you'll structure permissions in Angular:
-
Realm Roles β roles defined at the realm level, available across every client (application) in that realm. Good for broad designations like
adminoremployee. - Client Roles β roles scoped to a specific client (a specific application), useful when the same user has different responsibilities in different apps.
-
Groups β collections of users that can be assigned roles in bulk, useful for organizational structure (e.g.,
finance-team,support-team). - Claims β the actual key-value pairs embedded in the JWT payload, including roles, groups, and any custom attributes your Keycloak realm is configured to include.
Your Angular auth store reads these claims once, on token receipt, and normalizes them into the shape the rest of the application consumes.
5. RBAC vs. PBAC
Two authorization models tend to coexist in enterprise Angular apps:
-
RBAC (Role-Based Access Control) β access decisions based on a user's assigned role (
admin,editor,viewer). Simple, coarse-grained, easy to reason about. -
PBAC (Permission-Based Access Control) β access decisions based on specific, named permissions (
users:manage,billing:view) that may be composed from one or more roles.
A recurring enterprise pattern: use RBAC for broad structure (who's an admin) and PBAC for fine-grained UI decisions (which specific admin actions this admin can perform). Both should resolve through the same permission service, not through separate, parallel systems.
6. The Signal-Based Auth Store
The auth store is the single source of truth. It holds raw authentication state and nothing else β no derived logic lives here.
// auth.store.ts
import { Injectable, signal, computed } from '@angular/core';
interface AuthState {
isAuthenticated: boolean;
roles: string[];
permissions: string[];
groups: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthStore {
private _state = signal<AuthState>({
isAuthenticated: false,
roles: [],
permissions: [],
groups: [],
});
readonly isAuthenticated = computed(() => this._state().isAuthenticated);
readonly roles = computed(() => this._state().roles);
readonly permissions = computed(() => this._state().permissions);
readonly groups = computed(() => this._state().groups);
setSession(claims: { roles: string[]; permissions: string[]; groups: string[] }) {
this._state.set({
isAuthenticated: true,
roles: claims.roles,
permissions: claims.permissions,
groups: claims.groups,
});
}
clearSession() {
this._state.set({
isAuthenticated: false,
roles: [],
permissions: [],
groups: [],
});
}
}
Everything downstream β guards, interceptors, components β reads from AuthStore's public computed signals. Nothing outside this service ever calls .set() directly.
7. The Permission Service β computed() Derivations
The permission service is where RBAC and PBAC decisions actually get made, expressed as computed() signals built on top of the auth store.
// permission.service.ts
import { Injectable, computed, inject } from '@angular/core';
import { AuthStore } from './auth.store';
@Injectable({ providedIn: 'root' })
export class PermissionService {
private auth = inject(AuthStore);
readonly isAdmin = computed(() => this.auth.roles().includes('admin'));
readonly canManageUsers = computed(() =>
this.auth.permissions().includes('users:manage')
);
readonly canViewBilling = computed(() =>
this.auth.roles().includes('billing-admin') ||
this.auth.permissions().includes('billing:view')
);
hasPermission(permission: string) {
return computed(() => this.auth.permissions().includes(permission));
}
hasAnyRole(...roles: string[]) {
return computed(() => roles.some(r => this.auth.roles().includes(r)));
}
}
This is the centralization the golden rule asks for. Every guard, every interceptor, every component template resolves permission questions through this one service β never by reading AuthStore directly and reimplementing the logic.
8. Functional Guards: CanActivateFn & CanMatchFn
Modern Angular guards are plain functions, which makes them a natural fit for reading signals directly.
// admin.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router } from '@angular/router';
import { PermissionService } from './permission.service';
export const canActivateAdmin: CanActivateFn = () => {
const permissions = inject(PermissionService);
const router = inject(Router);
return permissions.canManageUsers()
? true
: router.createUrlTree(['/unauthorized']);
};
export const canMatchAdmin: CanMatchFn = () => {
const permissions = inject(PermissionService);
const router = inject(Router);
return permissions.canManageUsers()
? true
: router.createUrlTree(['/unauthorized']);
};
CanActivateFn runs after a route has already matched β the route module may already be resolving. CanMatchFn runs earlier, deciding whether the route matches at all, which means an unauthorized user's browser never even lazy-loads the protected feature module. For sensitive, code-split features, prefer CanMatchFn.
9. Route Data & Declarative Route Security
Rather than hardcoding permission strings inside every guard, route data can carry the required permission, letting a single generic guard handle all routes.
// app.routes.ts
export const routes: Routes = [
{
path: 'admin/users',
loadComponent: () => import('./admin/user-admin.component'),
canMatch: [permissionGuard],
data: { requiredPermission: 'users:manage' },
},
{
path: 'billing',
loadComponent: () => import('./billing/billing.component'),
canMatch: [permissionGuard],
data: { requiredPermission: 'billing:view' },
},
];
// permission.guard.ts
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { PermissionService } from './permission.service';
export const permissionGuard: CanMatchFn = (route) => {
const permissions = inject(PermissionService);
const router = inject(Router);
const required = route.data?.['requiredPermission'] as string | undefined;
if (!required) return true;
return permissions.hasPermission(required)()
? true
: router.createUrlTree(['/unauthorized']);
};
This turns your route table into a declarative expression of the authorization model instead of a set of one-off guard functions per feature.
10. Conditional Rendering with @if and Structural Directives
With permissions exposed as signals, templates read them directly β no subscriptions, no async pipe boilerplate.
<!-- toolbar.component.html -->
@if (permissions.canManageUsers()) {
<button (click)="openUserAdmin()">Manage Users</button>
} @else {
<span class="disabled-hint">Restricted</span>
}
@if (permissions.isAdmin()) {
<nav-admin-menu />
}
Because canManageUsers is a computed signal, Angular's change detection re-evaluates the block automatically whenever the underlying role or permission signal changes β no manual re-render logic required.
11. Permission & Role Directives
For repeated permission checks across many templates, a structural directive keeps the intent explicit and DRY.
// has-permission.directive.ts
import { Directive, TemplateRef, ViewContainerRef, inject, input, effect } from '@angular/core';
import { PermissionService } from './permission.service';
@Directive({ selector: '[hasPermission]', standalone: true })
export class HasPermissionDirective {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private permissions = inject(PermissionService);
hasPermission = input.required<string>();
constructor() {
effect(() => {
const allowed = this.permissions.hasPermission(this.hasPermission())();
this.viewContainer.clear();
if (allowed) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
}
<button *hasPermission="'users:manage'" (click)="openUserAdmin()">
Manage Users
</button>
A role directive follows the same shape, checking permissions.hasAnyRole(...) instead.
12. Feature Flags on Top of Signals
Feature flags and permissions solve related but distinct problems β flags gate a feature's rollout, permissions gate a user's access. Both can share the same reactive pattern:
// feature-flag.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private _flags = signal<Record<string, boolean>>({});
readonly flags = computed(() => this._flags());
isEnabled(flag: string) {
return computed(() => this._flags()[flag] ?? false);
}
setFlags(flags: Record<string, boolean>) {
this._flags.set(flags);
}
}
@if (featureFlags.isEnabled('new-billing-dashboard')()) {
<billing-dashboard-v2 />
}
Combining a permission check and a feature flag check for the same UI element is common β and both resolve the same way, through a computed signal.
13. HttpInterceptorFn: Attaching Tokens and Handling 403s
The interceptor attaches the current access token to outgoing requests and reacts when the backend rejects a request as unauthorized.
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { KeycloakService } from './keycloak.service';
import { catchError, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const keycloak = inject(KeycloakService);
const token = keycloak.getToken();
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((err) => {
if (err.status === 403) {
keycloak.refreshPermissions();
}
if (err.status === 401) {
keycloak.logout();
}
return throwError(() => err);
})
);
};
A 403 here is a meaningful signal: the frontend's permission signals may be stale relative to the backend's authoritative state (a role changed elsewhere, a permission was revoked). Re-syncing on 403, rather than silently failing, keeps the UI honest.
14. Silent Token Refresh with effect()
effect() is a natural fit for side effects tied to authentication state β like scheduling the next silent refresh whenever a session becomes active.
// token-refresh.service.ts
import { Injectable, effect, inject } from '@angular/core';
import { AuthStore } from './auth.store';
import { KeycloakService } from './keycloak.service';
@Injectable({ providedIn: 'root' })
export class TokenRefreshService {
private auth = inject(AuthStore);
private keycloak = inject(KeycloakService);
constructor() {
effect(() => {
if (this.auth.isAuthenticated()) {
this.keycloak.scheduleSilentRefresh();
}
});
}
}
Silent refresh uses the refresh token to obtain a new access token before the current one expires, without interrupting the user. When the auth store's session claims update after a refresh, every computed permission downstream updates automatically β that's the practical payoff of centralizing state in signals.
15. Session Expiration & Logout Flow
// keycloak.service.ts (excerpt)
import { Injectable, inject } from '@angular/core';
import { AuthStore } from './auth.store';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class KeycloakService {
private auth = inject(AuthStore);
private router = inject(Router);
logout() {
this.auth.clearSession();
// clear the Keycloak session / redirect to end-session endpoint
this.router.navigate(['/login']);
}
handleSessionExpired() {
this.auth.clearSession();
this.router.navigate(['/login'], { queryParams: { reason: 'session-expired' } });
}
}
Because every guard and template reads from AuthStore, calling clearSession() once is enough to cascade the logged-out state across the entire application β protected routes stop matching, admin menus disappear, and interceptors stop attaching a token, all from one call.
16. SSR Considerations
With server-side rendering, auth state has to be resolved before route guards evaluate on the server β otherwise you risk a flash of unauthorized content, or a guard making a decision against an empty auth store. In practice:
- Resolve and hydrate the auth store from the request's cookies/token during the SSR bootstrap, before the router activates.
- Guards should treat "auth state not yet resolved" as a distinct state from "unauthenticated" β don't redirect prematurely during hydration.
- Avoid rendering permission-gated UI on the server using client-only signals that haven't hydrated yet; prefer resolving critical auth state in a route resolver so SSR output matches the authenticated result.
17. Frontend Authorization vs. Backend Authorization (Zero Trust)
This is worth stating plainly, because it's the part of this architecture that's easiest to misread:
Frontend authorization is designed to improve navigation and user experience. It is not a security boundary. Hiding a button, disabling a menu item, or redirecting away from a route are UX decisions β they make the interface honest about what a user can do, and they save round trips to a backend that would reject the request anyway.
Every permission must still be validated by the backend. A user with browser dev tools open can re-enable a hidden button or navigate directly to a blocked route. None of that matters if the backend independently verifies the JWT and checks authorization on every request β which is the actual security boundary.
This is Zero Trust applied at the UI layer: never assume a request is safe because the frontend "wouldn't have allowed it." Frontend authorization improves the experience. Backend authorization guarantees the security. Enterprise systems need both, and confusing one for the other is how avoidable breaches happen.
18. Common Mistakes
- Checking
roles.includes(...)directly inside components instead of going through a shared permission service. - Treating a hidden button as sufficient protection for a sensitive action.
- Storing permission state in local component state instead of a shared signal-based store.
- Skipping silent refresh handling, causing sessions to expire silently mid-use.
- Duplicating the same permission logic across guards, interceptors, and templates instead of centralizing it.
- Trusting frontend authorization as if it were a security control rather than a UX layer.
19. Best Practices Checklist
- One auth store, exposed only through
computed()signals β components and guards never mutate state directly. - Guards, interceptors, and templates all read from the same permission service β one source of truth, testable in isolation.
- Use
CanMatchFnoverCanActivateFnwhen you want an unauthorized route's feature module to never even lazy-load. - Keep role and permission naming consistent between your Keycloak realm configuration and your Angular constants β mismatches here are a common source of silent bugs.
- Re-sync permission state on a
403rather than assuming frontend state is authoritative. - Resolve auth state before route guards evaluate under SSR to avoid unauthorized flashes.
- Always validate authorization again on the backend, regardless of what the frontend already checked.
20. Closing Thoughts
The real power of this architecture isn't that Angular can authenticate a user β Keycloak was already doing that. It's that Signals give Angular teams a way to make authorization predictable across an entire application: one store, one set of computed permissions, and every guard, interceptor, and template reading from the same reactive source.
In enterprise Angular applications, the teams that get this right aren't the ones with the most guards or the cleverest RxJS chains. They're the ones with one dedicated service exposing signals and computed permissions β which makes the whole system easier to test, easier to reason about, and far less likely to drift out of sync between what the UI shows and what a user is actually allowed to do.
How do you currently manage permissions across components and routes in your own Angular applications?
I share day-to-day insight on web development, best practices, performance, and modern Angular architecture. Follow along for more β Ouakala Abdelaaziz, Full Stack Developer, Programming Mastery Academy.
π More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
π Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
π LinkedIn β Professional discussions, architecture breakdowns, and engineering insights.
πΈ Instagram β Visuals, carousels, and designβdriven posts under the Terminal Elite aesthetic.
π§ Website β Articles, tutorials, and project showcases.
π₯ YouTube β Deepβdive videos and live coding sessions.
Top comments (0)