In this post, we’ll learn how to integrate Keycloak into an Angular SPA without writing a single line of backend code.
Prerequisites
This is the list of all the prerequisites:
- Node.js 22+
- Angular CLI 20+
- An installed Keycloak (v26+) server
- Basic Angular and TypeScript knowledge
- Docker and Docker Compose
- Visual Studio Code or another IDE
- Familiarity with HTTP concepts and browser security
Verify your setup:
node --version # v22.x
ng version # Angular CLI: 20.x
docker --version # Docker version 26+
Overview
Before writing code, we need to understand why this is safe.
Why PKCE Makes Browser-Only Auth Acceptable
Traditional OAuth 2.0 confidential clients use a client secret. Browsers can’t keep secrets — anything embedded in JavaScript is visible in DevTools. PKCE (Proof Key for Code Exchange, RFC 7636) was designed to solve exactly this:
- The app generates a random
code_verifier(43–128 character random string) - It computes
code_challenge = BASE64URL(SHA256(code_verifier)) - The
code_challengeis sent to Keycloak at redirect time - Keycloak stores the challenge and redirects back with an authorization
code - The app exchanges
code+code_verifier(the original, unhashed value) for tokens - Keycloak verifies:
BASE64URL(SHA256(code_verifier)) === stored code_challenge
An attacker who intercepts the authorization code cannot exchange it without the original code_verifier, which was only ever held in JavaScript memory and never transmitted. keycloak-js handles all of this automatically.
Token Types

Access tokens are JWTs. Your Angular app forwards them opaquely in HTTP headers. Your backend validates signatures using Keycloak’s JWKS endpoint.
In-Memory Token Storage
keycloak-js stores tokens in JavaScript memory — not localStorage or cookies. This protects against XSS-based token theft: a script injected by an attacker can’t steal tokens from memory the way it can from localStorage. The tradeoff: a full page reload triggers a silent SSO check rather than finding tokens locally.
Why Use Keycloak with Angular?
Using Keycloak with Angular offers several advantages:
- No custom authentication backend required
- Standards-based authentication using OAuth 2.0 and OpenID Connect
- Built-in user management
- Secure token handling
- Easy implementation of role-based authorization
- Enterprise-ready security features
What We’re Building
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌──────────────────────┐ ┌───────────────────────┐ │
│ │ Angular 20 SPA │◄────►│ keycloak-js adapter │ │
│ │ │ └───────────┬───────────┘ │
│ │ / Home (public) │ │ │
│ │ /dashboard (auth) │ │ OIDC / HTTPS │
│ │ /admin (role) │ │ │
│ └──────────────────────┘ │ │
└───────────────────────────────────────────┼──────────────-──┘
│
┌────────────▼────────────┐
│ Keycloak 26 │
│ (Docker Compose) │
│ │
│ realm: angular-demo │
│ client: angular-app │
│ (public, PKCE, OIDC) │
└─────────────────────────┘
Auth flow at a glance:
- User hits a protected route — Angular redirects to Keycloak
- User authenticates on Keycloak’s login page
- Keycloak redirects back to the app with an authorization code
keycloak-jsexchanges the code for access, refresh, and ID tokens (PKCE)- Access token is kept in memory; injected as
Authorization: Beareron API calls keycloak-angularmonitors activity and silently refreshes tokens in the background
No backend token relay needed. If you have a backend API, that service validates the JWT — not your Angular app.
Setting Up Keycloak with Docker Compose
Create docker-compose.yml in your project root:
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.6.3
container_name: keycloak-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
command: start-dev
volumes:
- keycloak_angular_data:/opt/keycloak/data
healthcheck:
test:
[
"CMD-SHELL",
"exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
volumes:
keycloak_angular_data:
Start Keycloak:
docker compose up -d
Wait about 30 seconds, then open http://localhost:8080. Sign in with admin / admin.
Production note:
start-devdisables TLS and uses an embedded H2 database. For production, usestartwith a PostgreSQL backend and proper TLS termination behind nginx or a cloud load balancer.
Configuring Keycloak
Create a Realm
A Keycloak realm is an isolated namespace managing its own users, clients, roles, and sessions — think of it as a tenant boundary.
- Click the realm dropdown (top-left) → Create realm
- Realm name:
angular-demo - Toggle Enabled: On
- Click Create

Create the Angular Client
- Go to Clients → Create client
- Client ID:
angular-app| Client type:OpenID Connect - Click Next
On Capability config:
- Client authentication:
Off— this is what makes it a public client with no secret - Standard flow:
On— enables Authorization Code Flow - Direct access grants:
Off— disable Resource Owner Password Credentials; it’s legacy and insecure - Click Next
On Login settings:
- Valid redirect URIs:
http://localhost:4200/* - Valid post logout redirect URIs:
http://localhost:4200/* - Web origins:
http://localhost:4200 - Click Save
Web Origins configures CORS. Keycloak rejects token requests from origins not on this list. Misconfiguring this is the most common source of CORS errors.
Enable PKCE
On the client’s Settings tab, enable Require PKCE and set the PKCE Method to S256. While Keycloak 21+ enables this by default for public clients, explicitly configuring it helps prevent unintended downgrades and ensures the setting remains enforced.
Create Realm Roles
- Go to Realm roles → Create role → Name:
user→ Save - Repeat for role:
admin
Create Test Users
john.doe (regular user):
- Users → Create new user
- Username:
john.doe| Email:john@example.com| Email verified: On - Credentials tab → Set password:
password| Temporary: Off - Role mapping tab → Assign role →
user
jane.admin (administrator):
- Create user
jane.admin| Email:jane@example.com| Email verified: On - Set password:
password| Temporary: Off - Assign roles:
userandadmin

Setting Up the Angular Project
Creating the Angular Project
ng new angular-keycloak-app \
--style=scss \
--routing \
--ssr=false
cd angular-keycloak-app
Install Keycloak dependencies:
npm install keycloak-angular@^19.0.1 keycloak-js@^26.1.0
Check the keycloak-angular compatibility matrix for the exact version aligned with your Angular release. The library follows Angular’s major versioning starting from v18.
Our project structure will look like this:
angular-keycloak-app/
├── public/
│ └── silent-check-sso.html ← SSO iframe redirect target
├── src/
│ ├── app/
│ │ ├── core/
│ │ │ ├── guards/
│ │ │ │ └── auth.guard.ts ← Route guard with RBAC
│ │ │ └── services/
│ │ │ └── auth.service.ts ← Keycloak wrapper
│ │ ├── pages/
│ │ │ ├── home/
│ │ │ ├── dashboard/
│ │ │ ├── admin/
│ │ │ └── unauthorized/
│ │ ├── shared/
│ │ │ └── navbar/
│ │ ├── app.component.ts
│ │ ├── app.config.ts ← provideKeycloak() bootstrap
│ │ ├── app.routes.ts
│ │ └── keycloak.config.ts ← Keycloak provider factory
│ └── environments/
│ ├── environment.ts
│ └── environment.prod.ts
└── docker-compose.yml
Environment Configuration
src/environments/environment.ts:
export const environment = {
production: false,
keycloak: {
url: 'http://localhost:8080',
realm: 'angular-demo',
clientId: 'angular-app',
},
};
src/environments/environment.prod.ts:
export const environment = {
production: true,
keycloak: {
url: 'https://auth.yourdomain.com',
realm: 'angular-demo',
clientId: 'angular-app',
},
};
Confirm that angular.json contains the file replacement under configurations.production:
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
Angular’s build system swaps the file automatically when you build with --configuration production.
Silent SSO Configuration
When a user refreshes the page, tokens are gone from memory. The silent SSO check lets keycloak-js verify an existing Keycloak session in a hidden iframe — restoring the authenticated state without a full redirect.
Create the redirect target:
public/silent-check-sso.html:
<!doctype html>
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
Files in the public/ directory (Angular 17+ default) are served at the root path. The silentCheckSsoRedirectUri must point to {origin}/silent-check-sso.html.
If your project uses the older
src/assets/convention instead ofpublic/, place the file atsrc/assets/silent-check-sso.htmland update the URI to{origin}/assets/silent-check-sso.html.
Keycloak Provider Configuration
This is the heart of the integration. Create a factory function that encapsulates all Keycloak provider configuration:
src/app/keycloak.config.ts:
import {
createInterceptorCondition,
IncludeBearerTokenCondition,
provideKeycloak,
withAutoRefreshToken,
AutoRefreshTokenService,
UserActivityService,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
} from 'keycloak-angular';
import { environment } from '../environments/environment';
/**
* Only attach bearer tokens to requests targeting our backend API.
* Update this pattern to match your actual API URL.
*/
const apiCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: /^(http:\/\/localhost:3000)(\/.*)?$/i,
});
export const provideKeycloakAngular = () =>
provideKeycloak({
config: {
url: environment.keycloak.url,
realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId,
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
checkLoginIframe: false,
pkceMethod: 'S256',
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'logout',
sessionTimeout: 300000,
}),
],
providers: [
AutoRefreshTokenService,
UserActivityService,
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [apiCondition],
},
],
});
Every option here is intentional:

Application Bootstrap
src/app/app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { includeBearerTokenInterceptor } from 'keycloak-angular';
import { routes } from './app.routes';
import { provideKeycloakAngular } from './keycloak.config';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(
withInterceptors([includeBearerTokenInterceptor])
),
provideKeycloakAngular(),
],
};
includeBearerTokenInterceptor is a functional HTTP interceptor from keycloak-angular. It reads the INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG token — the list of conditions you registered in keycloak.config.ts — and injects Authorization: Bearer <token> only on matching requests.
No APP_INITIALIZER boilerplate. provideKeycloak() handles initialization internally, which is the key improvement in keycloak-angular v18+.
Auth Service
Scattering raw Keycloak API calls across components makes refactoring painful and testing harder. Wrap everything in a service:
src/app/core/services/auth.service.ts:
import { Injectable, inject, Signal, computed } from '@angular/core';
import Keycloak from 'keycloak-js';
import {
KEYCLOAK_EVENT_SIGNAL,
KeycloakEventType,
typeEventArgs,
ReadyArgs,
} from 'keycloak-angular';
export interface UserProfile {
username: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly keycloak = inject(Keycloak);
private readonly keycloakSignal = inject(KEYCLOAK_EVENT_SIGNAL);
/**
* Reactive signal that reflects the current authentication state.
* Updates automatically on Keycloak lifecycle events — no subscriptions needed.
*/
readonly isAuthenticated: Signal<boolean> = computed(() => {
const event = this.keycloakSignal();
switch (event.type) {
case KeycloakEventType.Ready:
return typeEventArgs<ReadyArgs>(event.args);
case KeycloakEventType.AuthSuccess:
return true;
case KeycloakEventType.AuthLogout:
case KeycloakEventType.AuthRefreshError:
case KeycloakEventType.AuthError:
return false;
default:
return this.keycloak.authenticated ?? false;
}
});
async login(redirectPath = '/dashboard'): Promise<void> {
await this.keycloak.login({
redirectUri: `${window.location.origin}${redirectPath}`,
});
}
async logout(): Promise<void> {
await this.keycloak.logout({
redirectUri: window.location.origin,
});
}
async getProfile(): Promise<UserProfile | null> {
if (!this.keycloak.authenticated) return null;
const profile = await this.keycloak.loadUserProfile();
return {
username: profile.username ?? '',
email: profile.email ?? '',
firstName: profile.firstName ?? '',
lastName: profile.lastName ?? '',
roles: this.getRoles(),
};
}
getRoles(): string[] {
return this.keycloak.realmAccess?.roles ?? [];
}
hasRole(role: string): boolean {
return this.keycloak.hasRealmRole(role);
}
hasAnyRole(...roles: string[]): boolean {
return roles.some(role => this.keycloak.hasRealmRole(role));
}
}
The isAuthenticated computed signal reacts to Keycloak lifecycle events via KEYCLOAK_EVENT_SIGNAL. Two events matter most:
KeycloakEventType.Ready— fires when the adapter initializes;argsis a boolean indicating whether a session was already active.KeycloakEventType.AuthLogout— fires when the session ends (user logout or Keycloak session expiry).
Components consume isAuthenticated() as a plain signal — no subscriptions, no ngOnInit polling, automatic change detection.
Auth Guard
src/app/core/guards/auth.guard.ts:
import { inject } from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot } from '@angular/router';
import Keycloak from 'keycloak-js';
export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const keycloak = inject(Keycloak);
const router = inject(Router);
if (!keycloak.authenticated) {
await keycloak.login({
redirectUri: window.location.href,
});
return false;
}
const requiredRoles: string[] = route.data['roles'] ?? [];
if (requiredRoles.length === 0) return true;
const hasRequiredRole = requiredRoles.every(role => keycloak.hasRealmRole(role));
if (!hasRequiredRole) {
router.navigate(['/unauthorized']);
return false;
}
return true;
};
This guard handles two distinct scenarios:
- Not authenticated → redirects to Keycloak login, preserving the intended URL via
redirectUri: window.location.href - Authenticated but missing roles → navigates to
/unauthorized(not back to login — the user is authenticated, just not authorized)
The distinction matters for UX: sending an authenticated user to the login page is confusing and breaks the SSO flow.
Route Configuration
src/app/app.routes.ts:
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/home/home.component').then(m => m.HomeComponent),
},
{
path: 'dashboard',
loadComponent: () =>
import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard],
},
{
path: 'admin',
loadComponent: () =>
import('./pages/admin/admin.component').then(m => m.AdminComponent),
canActivate: [authGuard],
data: { roles: ['admin'] },
},
{
path: 'unauthorized',
loadComponent: () =>
import('./pages/unauthorized/unauthorized.component').then(m => m.UnauthorizedComponent),
},
{
path: '**',
redirectTo: '',
},
];
All routes use loadComponent for lazy loading. Keycloak initializes once at app startup via provideKeycloak() — not per-route. The guard runs against the already-initialized Keycloak state, so there’s no repeated async initialization overhead.
Role requirements are passed through data.roles — a clean separation between routing structure and authorization policy.
Running and Testing
Start Keycloak (if not running):
docker compose up -d
Start Angular:
ng serve
Open http://localhost:4200 and walk through these scenarios:
Scenario 1 — Public home page Navigate to http://localhost:4200. No redirect. The Login button is visible.

Scenario 2 — Direct protected URL access (unauthenticated). Navigate directly to http://localhost:4200/dashboard. The guard triggers, browser redirects to Keycloak login, and after authentication Keycloak sends you back to /dashboard.
Scenario 3 — Regular user access. Log in as john.doe / password. Dashboard loads with user profile and user role badge. The Admin nav link is absent. Navigate to http://localhost:4200/admin — the guard redirects to /unauthorized.

Scenario 4 — Admin user access Logout. Log in as jane.admin / password. The Admin nav link appears. Navigate to /admin — access granted.

Scenario 5 — Session persistence across refresh. Log in, then refresh the page. You should remain authenticated via the silent SSO check (a brief Loading... flicker on the dashboard is normal while the iframe check completes).
Performance Considerations
- Silent SSO overhead: The
check-ssoinit option fires a hidden iframe on every cold page load. This adds one Keycloak round-trip on startup. For apps where most users are already authenticated, this is a worthwhile tradeoff — no full redirect required. For anonymous-first apps with occasional auth, consider lazy-checking only when a protected route is hit. - Token refresh timing:
withAutoRefreshTokenusessessionTimeout(inactivity-based). Align this with Keycloak’s SSO Session Idle setting in the realm. If Keycloak expires the session before the Angular app detects inactivity, the next token refresh attempt will fail silently and the user will find themselves logged out unexpectedly. - Lazy loading: All routes use
loadComponent, so component code is loaded on demand. Keycloak initialization happens once at bootstrap — not per-route load. The guard checks the already-initialized Keycloak state synchronously (after theawait keycloak.login()redirect path). - JWKS caching in your backend: When your API validates tokens, cache Keycloak’s JWKS response. Fetching
{keycloak-url}/realms/{realm}/protocol/openid-connect/certson every request is expensive and creates a hard dependency on Keycloak availability in the request path.
Common Pitfalls
- CORS errors on token requests Symptom:
401 UnauthorizedorAccess-Control-Allow-Originin the console. Cause:Web originsnot configured in the Keycloak client. Fix: Addhttp://localhost:4200to Web Origins in the Keycloak client configuration. In production, add your actual domain. Do not use*— Keycloak ignores wildcard web origins for security reasons. - Infinite redirect loop Symptom: Browser keeps cycling between the app and Keycloak login. Cause: The post-login redirect URI doesn’t match Valid redirect URIs in the client. Fix: Confirm
http://localhost:4200/*(with wildcard) is listed. Keycloak exact-matches redirect URIs and the*wildcard must be explicit. - Bearer token not sent to API Symptom: API requests have no
Authorizationheader. Cause: TheurlPatternincreateInterceptorConditiondoesn’t match the API URL. Fix: Test your regex at regex101.com before deploying. Remember the interceptor applies the pattern against the full URL including protocol. - Roles missing from token Symptom:
keycloak.hasRealmRole('admin')returnsfalseeven though the user has the role. Cause: Realm roles (realm_access.roles) and client roles (resource_access.<clientId>.roles) are in different locations in the JWT. Fix: Usekeycloak.hasRealmRole(role)for realm-level roles. Usekeycloak.hasResourceRole(role, 'angular-app')for client-specific roles. Checkkeycloak.tokenParsedin the browser console to confirm which path your roles are in. - Silent check broken after deployment Symptom: Silent SSO check fails with origin mismatch or
Blocked a frame with origin...errors. Cause:silent-check-sso.htmlis not being served, or the deployment origin doesn’t matchwindow.location.originat build time. Fix: Verify the file is present in the deployedpublic/output. Confirm the Angular app’s domain exactly matches what’s registered in Keycloak’s Valid redirect URIs and Web Origins. checkLoginIframeconsole errors Symptom:[Keycloak] Error during login iframe initializationin the console. Cause: Browser blocking cross-origin iframe communication (common in Firefox with strict privacy settings, Safari with ITP). Fix: KeepcheckLoginIframe: false. Token refresh still works via the refresh token — the login iframe check is redundant whencheck-ssois configured.
Conclusion
🏁 Well done !!. In this post, we learned how to integrate Keycloak into an Angular SPA without writing a single line of backend code.
Integrating Keycloak with Angular is one of the simplest ways to implement secure authentication and authorization in a Single Page Application.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for reading!! See you in the next post.