ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeArchitectureI Built an Angular Authentication Layer for the Signals Era
Architecture

I Built an Angular Authentication Layer for the Signals Era

How I designed `ngx-auth-client` around reactive state, provider-agnostic authentication, functional guards, and safer token handling.

Ismail ZAHIR
Ismail ZAHIR
Software Engineer
August 31, 2026
16 min read
Series

Engineering in Practice

Part 5 of 5

Prev
Next
I Built an Angular Authentication Layer for the Signals Era
#authentication#oauth2#security#angular#Architecture

Authentication is one of those concerns that quickly spreads across an Angular application.

A component needs to know whether the user is authenticated. A route needs authorization rules. HTTP requests need access tokens. The application needs to react when a session expires. Tests need to simulate authenticated and unauthenticated users.

And once an identity provider such as Keycloak is introduced, it becomes very easy for provider-specific concepts to leak into the rest of the application.

I wanted a different approach.

So I built ngx-auth-client, an Angular authentication library designed around Angular signals, a provider-agnostic authentication layer, functional route guards, in-memory token handling, and testability.

The goal wasn't to create another wrapper around an identity provider.

The goal was to create an authentication layer that belongs to the Angular application rather than to a particular authentication provider.

The application should depend on authentication capabilities, not on the identity provider implementing them.

If you want to follow along with the implementation, you can install the library with:

npm install @ismailza/ngx-auth-client keycloak-js

Then import the pieces you need:

import {
  provideAuth,
  authGuard,
  authTokenInterceptor
} from '@ismailza/ngx-auth-client';

import { withKeycloak } from '@ismailza/ngx-auth-client/keycloak';
import { withFakeAuth } from '@ismailza/ngx-auth-client/testing';

There is an important detail in that installation command.

keycloak-js is an optional peer dependency. The core package has no identity-provider dependency. Only the /keycloak entry point requires it.

That distinction is not just a packaging detail. It is part of the architecture.

This is the story of the decisions behind the project.


Why build another authentication library?

Angular applications already have many ways to implement authentication.

You can integrate an identity provider directly, use an SDK, write your own authentication service, or build a small abstraction around an existing provider.

I've used the direct integration approach before.

It works.

Until authentication starts appearing everywhere.

You might end up with code such as:

if (keycloak.authenticated) {
  // ...
}

or:

const token = await keycloak.updateToken();

or:

keycloak.login();

The problem isn't that these APIs are bad.

The problem is that the application starts knowing which authentication provider it is using.

Your components, route guards, interceptors, and services become coupled to Keycloak.

I wanted to move that dependency to the edge of the architecture.

Instead of:

Angular Application
        โ†“
     Keycloak

I wanted:

Angular Application
        โ†“
 Authentication Layer
        โ†“
 Authentication Provider

That distinction became the foundation of ngx-auth-client.


Authentication state should be Angular state

One of the first decisions I made was to make authentication state reactive using Angular signals.

Instead of exposing authentication state through imperative getters, the library exposes it as reactive state:

auth.authenticated()
auth.claims()
auth.roles()
auth.profile()

A component can consume that state directly:

@Component({
  template: `
    @if (auth.authenticated()) {
      <p>Welcome back!</p>
    } @else {
      <p>Please sign in.</p>
    }
  `
})
export class HomeComponent {
  protected readonly auth = inject(AuthService);
}

The important part isn't the syntax.

It's that authentication becomes normal reactive application state.

When authentication changes, Angular can react naturally. A component doesn't need to subscribe to provider-specific authentication events just to keep its UI synchronized.

One service for application code

AuthService is the single thing application code needs to inject.

It adds several application-facing capabilities on top of the underlying provider port:

  • initialization state with ready(), initError(), and whenReady()

  • capability detection such as canRegister()

  • role predicates such as hasRole(), hasAnyRole(), and hasAllRoles()

The underlying provider contract remains deliberately small.

Setting everything up is a single provider call:

export const appConfig: ApplicationConfig = {
  providers: [
    provideAuth(
      withKeycloak({
        url,
        realm,
        clientId
      })
    ),

    provideHttpClient(
      withInterceptors([authTokenInterceptor])
    ),

    provideRouter(routes)
  ]
};

Bootstrap doesn't have to wait for authentication

One subtle design decision is how initialization works.

The application doesn't block bootstrap while the authentication session is restored.

The adapter starts initialization as soon as the injector is created. whenReady() resolves when initialization settles, and the route guard waits for that before making an authorization decision.

That means unprotected application UI can render immediately while protected routes still wait for authentication state to become reliable.

This avoids a common race condition:

Application starts
       โ†“
Authentication restoration starts
       โ†“
Protected route evaluates too early
       โ†“
User appears unauthenticated
       โ†“
Incorrect redirect

The guard instead follows:

Application starts
       โ†“
Authentication restoration starts
       โ†“
Protected route waits for readiness
       โ†“
Authentication decision

A small profile detail

profile() is null until loadProfile() is called.

Adapters load the profile lazily, so:

auth.profile()?.email

won't display anything until the profile has actually been loaded.

This is intentional. Authentication state and profile loading are different concerns.


Designing around AuthProvider

Once I decided that the application shouldn't depend directly on Keycloak, I needed an abstraction.

That's AuthProvider.

The architecture looks like this:

                   Angular Application
                           |
                           v
                      AuthService
                           |
                           v
                      AuthProvider
                       /        \\
                      v          v
              Keycloak       Other Provider
               Adapter          Adapter

The application interacts with the authentication layer.

The authentication layer interacts with an AuthProvider.

The provider handles the identity-provider-specific implementation.

The port is deliberately small:

interface AuthProvider {
  readonly authenticated: Signal<boolean>;
  readonly claims: Signal<Claims | null>;
  readonly roles: Signal<readonly string[]>;

  init(): Promise<void>;
  getToken(): Promise<string>;
  login(options?: LoginOptions): Promise<void>;
  logout(options?: LogoutOptions): Promise<void>;
}

Being able to show the entire contract on one screen is part of the argument.

This is the entire surface that the core, the guard, and the interceptor are allowed to depend on.

There is one rule that shaped the split:

State is signals, operations are promises.

The distinction is state versus action.

For example, authentication status is state:

authenticated()

Token refresh is an operation:

getToken()

A token refresh shouldn't become another piece of application state that every consumer needs to understand.


Keycloak is an adapter, not the architecture

I intentionally didn't make Keycloak the center of the library.

It's a provider adapter exposed through its own entry point:

ngx-auth-client
โ”‚
โ”œโ”€โ”€ Core
โ”‚   โ”œโ”€โ”€ AuthService
โ”‚   โ”œโ”€โ”€ AuthProvider
โ”‚   โ”œโ”€โ”€ authGuard
โ”‚   โ””โ”€โ”€ authTokenInterceptor
โ”‚
โ”œโ”€โ”€ Keycloak
โ”‚   โ””โ”€โ”€ KeycloakAuthProvider
โ”‚
โ””โ”€โ”€ Testing
    โ””โ”€โ”€ FakeAuthProvider

This is essentially the Ports and Adapters pattern.

AuthProvider is the port.

The provider implementations are adapters.

Identity providers are infrastructure. The application should depend on authentication concepts, not infrastructure-specific APIs.

If tomorrow the application needs another identity provider, the goal is to implement another adapter rather than rewrite authentication logic throughout the application.

The boundary isn't only TypeScript

This is the part I care about most.

A provider abstraction isn't very useful if the package itself still forces every consumer to install the provider SDK.

That's why the package structure matters.

The core package has no identity-provider dependency.

keycloak-js is an optional peer dependency and is only required by:

@ismailza/ngx-auth-client/keycloak

If an application never imports the Keycloak entry point, Keycloak isn't part of its dependency graph.

So the separation exists at two levels:

Architecture
    โ†“
AuthProvider abstraction
    โ†“
Provider adapters

and:

Package
    โ†“
Core entry point
    โ†“
Optional provider entry points

That's the difference between claiming a separation and actually shipping one.


Capabilities instead of one giant interface

There's a familiar failure mode in authentication abstractions.

It's tempting to create one huge interface containing every possible capability:

interface AuthProvider {
  login(): Promise<void>;
  logout(): Promise<void>;
  refresh(): Promise<void>;
  getToken(): Promise<string>;
  changePassword(): Promise<void>;
  linkAccount(): Promise<void>;
  // ...
}

But not every identity provider supports every operation.

Folding every provider's features into one interface forces adapters to implement methods they cannot support.

That's how multi-provider abstractions eventually become a lowest common denominator โ€” followed by a getNativeClient() escape hatch.

I wanted to avoid that.

The core provider stays small, and additional functionality is represented as optional capabilities:

interface SupportsRegistration {
  register(options?: RegisterOptions): Promise<void>;
}

interface SupportsAccountManagement {
  accountManagement(): Promise<void>;
}

interface SupportsPasswordUpdate {
  updatePassword(options?: PasswordUpdateOptions): Promise<void>;
}

interface SupportsProfile {
  readonly profile: Signal<UserProfile | null>;
  loadProfile(): Promise<UserProfile>;
}

The core detects these capabilities through type guards.

AuthService then exposes the result as signals:

auth.canRegister()
auth.canManageAccount()
auth.canUpdatePassword()
auth.canLoadProfile()

That has a practical benefit.

The application can hide UI that the configured provider cannot actually deliver:

@if (auth.canRegister()) {
  <button (click)="auth.register()">
    Create an account
  </button>
}

And if application code calls an unsupported capability anyway, the library throws an UnsupportedCapabilityError that identifies the capability and the corresponding can* check.

Not a silent no-op.

Not a confusing:

undefined is not a function

The Keycloak adapter currently implements all four capabilities because Keycloak supports them.

It isn't stubbing methods just to satisfy a contract.

Don't force every authentication provider to implement capabilities it doesn't support.


Role normalization is what makes the port credible

Here's where "provider-agnostic" stops being a claim and becomes something you can point at.

Identity providers disagree about where roles live.

For example:

  • Keycloak can expose roles through realm_access and resource_access

  • Auth0 commonly uses a namespaced custom claim

  • Cognito can use cognito:groups

  • Microsoft Entra ID can expose application roles through roles

If that provider-specific structure reaches your Angular components, the abstraction has already failed.

So the rule is:

Role normalization belongs in the adapter.

The core only sees:

readonly roles: Signal<readonly string[]>;

Everything above the port speaks in roles.

Everything below it speaks the provider's vocabulary.

Keycloak role extraction

For Keycloak, the adapter can be configured to extract realm and resource roles:

withKeycloak({
  url,
  realm,
  clientId,
  roles: {
    realm: true,
    resource: ['my-app']
  }
});

resource: true takes roles from every client.

An explicit array takes roles only from the clients you name.

That distinction matters when several clients contain roles with the same name. Explicitly selecting the resource clients makes the authorization boundary easier to reason about.

Custom role mapping

If roles live somewhere non-standard, extraction can be overridden:

withKeycloak({
  url,
  realm,
  clientId,
  mapRoles: (claims) =>
    (claims['groups'] as string[] | undefined) ?? []
});

Now the rest of the application doesn't care where those roles came from.

That is the value of the adapter.


Route authorization: functional guards, anyOf and allOf

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to access?

Knowing that a user is authenticated tells us nothing about whether they can access an administration page.

That's what the route guard handles.

Angular's functional APIs provide a clean way to express authorization through route metadata:

{
  path: 'admin',
  canActivate: [authGuard],
  data: {
    auth: {
      anyOf: ['admin', 'owner']
    }
  }
}

anyOf means:

admin OR owner

allOf requires every listed role:

{
  path: 'billing',
  canActivate: [authGuard],
  data: {
    auth: {
      allOf: ['admin', 'finance']
    }
  }
}

Which means:

admin AND finance

This keeps authorization requirements close to the route instead of scattering authorization logic throughout components.


What the guard actually does

The route configuration is the visible part.

The behavior underneath it is just as important.

1. It waits for authentication readiness

The guard awaits whenReady() before deciding whether the route can be activated.

This avoids a common hard-refresh race:

Page reload
   โ†“
Session restoration starts
   โ†“
Guard runs immediately
   โ†“
authenticated = false
   โ†“
Incorrect login redirect

Instead:

Page reload
   โ†“
Session restoration starts
   โ†“
Guard waits
   โ†“
Session restoration settles
   โ†“
Authorization decision

2. Unauthenticated users are redirected to login

When a user isn't authenticated, the guard redirects to the login flow with a return URL.

The goal is simple:

User requests /admin
       โ†“
Not authenticated
       โ†“
Login
       โ†“
Return to /admin

3. Authenticated but unauthorized users are forbidden

A user can be authenticated and still lack the required permissions.

Those users are sent to the configured forbiddenRoute, which defaults to:

/forbidden

You can also set it to null if you want the guard to refuse activation without performing navigation.

4. Non-browser environments fail closed

On a non-browser platform, the guard returns false without attempting a login redirect.

Because the tokens are held in memory, there is no browser session available for the server to evaluate.

Rendering protected content into an SSR response could also create caching problems if that response is later served to another user.

Failing closed is therefore the safer behavior.


Requirements accumulate through the route tree

There's another detail that matters.

The guard doesn't look only at the leaf route.

It walks the route tree and accumulates authorization requirements.

For example:

{
  path: 'admin',
  canActivate: [authGuard],
  data: {
    auth: {
      anyOf: ['admin', 'owner']
    }
  },
  children: [
    {
      path: 'billing',
      data: {
        auth: {
          allOf: ['finance']
        }
      },
      loadComponent: () => import('./billing.component')
    }
  ]
}

The billing route inherits the parent requirement and adds its own.

Conceptually:

admin OR owner
        AND
      finance

There is an important subtlety here.

Each anyOf group remains separate.

Suppose we have:

Parent:
anyOf(admin, owner)

Child:
anyOf(finance, billing)

These are two independent requirements.

They should not be flattened into:

anyOf(admin, owner, finance, billing)

Otherwise a user with only admin would satisfy the combined requirement.

Keeping the groups separate preserves the intended authorization semantics.

It's a small implementation detail with a large security consequence.


The token interceptor should fail closed

Route protection is only half the story.

Once a user is authenticated, the application needs to attach an access token to API requests.

A naive interceptor might attach the token to every outgoing request.

I don't think that's a good default.

An Angular application can communicate with many destinations:

  • your backend API

  • third-party APIs

  • analytics services

  • CDNs

  • external resources

You don't want an access token accidentally attached to an unrelated destination.

So ngx-auth-client uses an allowlist.

Request
   |
   v
Does the URL match the allowlist?
   |
   +---- No ----> Send unchanged
   |
   +---- Yes ---> Attach bearer token

The default pattern is:

/^\\/api(\\/.*)?$/

That means same-origin relative /api/* requests.

You can configure a more specific URL pattern or restrict injection by HTTP method:

provideAuth(
  withKeycloak({
    url,
    realm,
    clientId
  }),
  {
    bearer: {
      urlPattern: /^https:\\/\\/api\\.example\\.com\\//,
      methods: ['GET', 'POST']
    }
  }
);

You can also disable bearer injection completely with:

bearer: false

when another layer is responsible for authorization headers.

The interceptor waits for readiness

A request fired during the initial authentication restore shouldn't race the session.

The interceptor waits for authentication readiness before deciding whether a token can be attached.

Otherwise you can get the classic:

Application starts
       โ†“
API request fires
       โ†“
Authentication is still restoring
       โ†“
Request goes out without token
       โ†“
401

The interceptor avoids that race.

Unauthenticated requests remain unauthenticated

If there is no authenticated session, the request proceeds without a bearer token.

The API can then return 401 Unauthorized.

The client doesn't need to turn every unauthenticated request into a client-side exception.

Regular expressions can have state

One small implementation detail caused me to think more carefully about the interceptor.

JavaScript regular expressions with the g or y flags maintain lastIndex.

That means repeatedly calling:

pattern.test(url)

can produce alternating results.

A pattern can effectively behave like:

match
no match
match
no match
...

That's an especially nasty failure mode for an authentication interceptor because the symptom is:

"Why is my Authorization header missing from every other request?"

So the library strips g and y flags from configured patterns before matching.

The underlying principle is:

Only attach credentials where they are explicitly expected.


Why keep tokens in memory?

ngx-auth-client doesn't persist access tokens in localStorage or sessionStorage.

More precisely, the library itself doesn't store the token in browser-persistent storage. With the Keycloak adapter, keycloak-js holds the token in memory.

At first, this raises an obvious question:

What happens after a page reload?

The session can still be restored because the Keycloak adapter uses:

onLoad: 'check-sso'

The browser doesn't need to persist the access token itself. Keycloak's SSO session can be checked again when the application initializes.

The reason for avoiding persistent token storage is straightforward.

Browser-accessible persistent storage can increase the impact of a successful XSS attack because malicious JavaScript may be able to read stored credentials.

Keeping tokens in memory doesn't make an application immune to XSS.

No authentication architecture can compensate for an insecure application.

But it does reduce the places where credentials are persistently stored.

That's a trade-off I prefer for this kind of client-side authentication layer.

Don't persist credentials unless persistence is actually required.


PKCE โ€” and making the default survive real configuration

For browser-based applications, authorization-code flow with PKCE is the modern OAuth approach.

The Keycloak adapter defaults to:

pkceMethod: 'S256'

I want to be precise about this.

ngx-auth-client doesn't implement the OAuth authorization-code exchange itself. keycloak-js handles that.

The library chooses the default and makes sure the default survives the way real applications build configuration.

The undefined configuration problem

Configuration objects are often assembled dynamically:

const config = {
  pkceMethod: environment.pkceMethod
};

If environment.pkceMethod is undefined, a later object spread can accidentally overwrite the default:

{
  pkceMethod: 'S256',
  ...config
}

The resulting value becomes:

pkceMethod: undefined

The secure default has effectively disappeared.

So the adapter removes undefined values before merging configuration.

It's a small implementation detail, but it's an important lesson:

Security defaults aren't only about choosing the right value. They're also about making sure the value survives real configuration.


Token refresh should be invisible to the application

I didn't want token-refresh logic spread throughout the application.

A component shouldn't need to contain:

if (tokenIsAboutToExpire) {
  await refreshToken();
}

That's infrastructure logic.

The application should simply ask for an access token.

The authentication layer handles the lifecycle.

Conceptually:

Application
    |
    | getToken()
    v
AuthProvider
    |
    | Token still valid?
    |       |
    |       +---- Yes ---> return token
    |
    |       +---- No ----> refresh
    |
    v
Return valid token

getToken() only needs to interact with the network when the token is close to expiry.

With the Keycloak adapter, the default minimum token validity is 30 seconds.

Otherwise, the existing token can be returned immediately.

What happens when refresh fails?

This is where the signal-based architecture becomes useful again.

If the session genuinely expires and refresh fails, the adapter clears authentication state through the provider's authentication events.

The result is a state change:

authenticated()
    true
      โ†“
    false

The rest of the application doesn't need to catch a Keycloak-specific error just to update its UI.

The application is already reacting to authentication state.

That's what I mean by making token lifecycle management invisible: the infrastructure handles the lifecycle while the application sees the resulting state.


Testing without Keycloak

Authentication tests become painful when they depend on a real identity provider.

You don't want every unit test to require:

Angular Test
     โ†“
Keycloak
     โ†“
Realm
     โ†“
Client
     โ†“
User
     โ†“
Token

That's too much infrastructure for a unit test.

So the library provides a fake authentication provider:

TestBed.configureTestingModule({
  providers: [
    provideAuth(
      withFakeAuth({
        authenticated: true,
        roles: ['admin'],
        claims: {
          sub: 'user-1',
          name: 'Test User'
        }
      })
    )
  ]
});

The important part is that the fake provider implements the same provider port as the production adapter.

The component doesn't know whether authentication came from Keycloak or the fake.

That's exactly what we want from an abstraction.

The fake provider is more than a stub

It can also record calls:

loginCalls
logoutCalls
registerCalls
...

That means tests can assert not only that a guard denied access, but also that the expected login flow was triggered with the correct information.

It can also simulate failures such as:

failInit
failToken

This makes it possible to test failure paths without depending on a real identity provider.

And the fake provider can change state during a test:

const fake = TestBed.inject(FakeAuthProvider);

fake.setRoles(['viewer']);

That makes it possible to test how the application reacts to changing authentication state.

There is also a useful architectural side effect here.

The fake provider is a test of the abstraction itself.

If a fake implementation is difficult to build without knowing Keycloak internals, the port probably isn't truly provider-agnostic.

Writing the fake helped validate that boundary.


Signals are particularly important for zoneless Angular

I've described signals as a design choice.

For zoneless Angular applications, the distinction becomes more important.

Consider:

@if (keycloak.authenticated) {
  ...
}

versus:

@if (auth.authenticated()) {
  ...
}

The first approach exposes a mutable property from the identity-provider client.

The second exposes a signal that Angular understands as reactive state.

When the identity provider reports an authentication event, the adapter converts that event into a signal update:

Identity provider event
        โ†“
Adapter
        โ†“
Signal update
        โ†“
Angular reactivity
        โ†“
UI / Guards / Application logic

For example, the Keycloak adapter can react to events such as:

  • authentication success

  • token refresh success

  • logout

  • token expiration

The adapter is the single place where those provider-specific events become Angular state.

Nothing downstream needs to understand how Keycloak reports them.

That's the layer I wanted to write once and reuse.


Compatibility is part of the library

Building an Angular library isn't only about making the code work on your machine.

If you're publishing a package, you also need to think about the environments consuming it.

ngx-auth-client supports Angular 17 through 22 from a single release.

That raises an important question:

How do you know that the package you publish actually works?

Validating the repository source isn't enough.

I want to validate the artifact users install:

Source
  โ†“
Build
  โ†“
npm pack
  โ†“
Package artifact
  โ†“
Install in each supported Angular version
  โ†“
Build + type-check + test

A repository can be perfectly healthy while the published package has problems with:

  • package exports

  • generated declarations

  • build output

  • dependencies

  • peer dependencies

  • packaging configuration

For an open-source library, the package is the product.

The compatibility matrix comes from the package

The supported Angular versions are derived from peerDependencies rather than maintained as a completely separate list.

That reduces the risk of having documentation or CI claim support for versions that the package doesn't actually declare.

Supporting Angular 17โ€“22 creates real constraints

Supporting six Angular majors from a single release isn't free.

One important decision was to build the Keycloak integration directly on keycloak-js instead of keycloak-angular.

Why?

Because a wrapper that tracks a specific Angular major would make a broad compatibility range much harder to maintain.

Owning the Angular integration gives the library more control over its supported range.

There is also an example of what compatibility work actually looks like in the implementation.

provideAuth() uses ENVIRONMENT_INITIALIZER, which is deprecated in newer Angular versions.

Its replacement, provideEnvironmentInitializer, isn't available in the oldest Angular versions supported by the library.

So for a library targeting Angular 17-22, the practical choice is to use the API that remains available across the entire supported range.

It's not always about using the newest API.

Sometimes compatibility means deliberately choosing the API that works across the versions you promise to support.


Documentation is part of the product

A library isn't finished when the implementation works.

Developers need to understand:

  • what problem it solves

  • how to install it

  • how to configure it

  • how authentication works

  • how to configure guards

  • how to configure the interceptor

  • how to use the Keycloak adapter

  • how to test it

That's why I created a dedicated documentation website for ngx-auth-client.

The repository contains the implementation.

The documentation site provides the structured entry point for developers who want to evaluate and use the library.

You can explore both here:

  • GitHub: https://github.com/ismailza/ngx-auth-client

  • Documentation: https://ismailza.github.io/ngx-auth-client


What I learned building it

Building ngx-auth-client reinforced a few principles for me.

1. An abstraction should remove coupling, not hide complexity

If all I've done is wrap Keycloak APIs with different names, I haven't created a useful abstraction.

I've created a translation layer.

The real test is whether the application can stop knowing Keycloak exists.


2. Reactive state should be exposed as reactive state

Angular provides signals as a first-class reactive primitive.

Authentication state is a natural candidate for them.

Instead of making every application consumer translate provider-specific events into Angular state, the adapter should do that once.


3. Provider differences belong at the boundary

Roles are a good example.

The application wants:

roles()

It doesn't want to know about:

realm_access
resource_access
cognito:groups

That transformation belongs in the adapter.

The same principle can be applied to other provider-specific concepts.


4. Security defaults need to survive real usage

It's easy to say:

"PKCE is enabled by default."

It's harder to make sure an application's configuration cannot accidentally erase that default with undefined.

The same applies to token injection.

An allowlist is safer than assuming every outgoing request is trusted.


5. Testability is an architectural consequence

The fake provider wasn't added simply because testing is convenient.

It became possible because the provider boundary is real.

The application can be tested against the same abstraction used in production.

That's one of the best signals that an abstraction is actually useful.


What's next?

ngx-auth-client is still evolving.

There are several areas I want to continue exploring:

  • additional provider adapters

  • richer authorization capabilities

  • better testing utilities

  • API evolution without unnecessary breaking changes

  • compatibility as new Angular versions are released

But additional providers are probably the most interesting test.

It's easy to claim that an architecture is provider-agnostic when there is only one provider.

The real test is implementing a second one without changing the application-facing API.

That's something I want the project to prove over time.


Final thoughts

I didn't build ngx-auth-client because Angular authentication was impossible without another library.

I built it because I wanted authentication to have a cleaner architectural boundary in my applications.

The main idea is simple:

Your Angular application should consume authentication capabilities, not depend directly on an identity provider.

Signals make authentication state feel like native Angular state.

AuthProvider keeps the application independent from the identity provider.

Role normalization keeps provider-specific claim structures out of application code.

Functional guards make authorization declarative.

An allowlisted interceptor limits where credentials are sent.

In-memory token handling avoids unnecessary persistent credential storage.

And a fake provider makes authentication-dependent code easier to test.

The result isn't simply a Keycloak wrapper.

It's an attempt to build an Angular-native authentication layer that can evolve independently from the identity provider underneath it.

If you're interested in the implementation, the project is open source:

GitHub: https://github.com/ismailza/ngx-auth-client

Documentation: https://ismailza.github.io/ngx-auth-client

And if you're building Angular libraries yourself, I'd be interested in how you approach authentication, compatibility, and provider abstraction.

Series

Engineering in Practice

Part 5 of 5

Prev
Next

Comments (0)

Login to post a comment.

Ismail ZAHIR
Ismail ZAHIR

Software Engineer

Iโ€™m a software engineer who loves turning ideas into real, useful products. I enjoy building things that make life easier and more enjoyable.

Subscribe to Ismail ZAHIR's Newsletter
I Built an Angular Authentication Layer for the Signals Era

More from Ismail ZAHIR

View profile

Building a Reusable Keycloak Theme Architecture

Customizing a Keycloak login theme is styling. Doing it for a second brand without copying the first one is architecture. Here is how a base theme, four theme-resolution rules, and 58 design tokens turn a new branded login and email experience into twenty lines of properties and a logo.

9 minAug 15

OAuth 2.0, OpenID Connect, and Keycloak: Understanding Modern Authentication

OAuth 2.0, OpenID Connect, JWT, access tokens, refresh tokens, Keycloak. These get mentioned in the same breath so often that they blur into a single vague thin...

10 minAug 7

Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)

Most Angular libraries claim compatibility across multiple Angular versionsโ€”but how many actually verify it? Here's why I stopped testing my source code and started validating the packaged npm artifact that users really install.

5 minJul 31

I Stopped Copy-Pasting the Same Angular ApiService. Hereโ€™s What I Built Instead

Four projects in, I was copying the same api.service.ts instead of writing it. The design decisions behind extracting it into @ismailza/ngx-api-client.

7 minJul 30