Skip to content

Setting up auth via Keycloak

After the technical skeleton, the tournament app runs as a preview system. Frontend, API gateway, service, database, and RabbitMQ all start. That’s not domain functionality yet, but it’s an important technical vertical slice.

The next step looks like infrastructure again at first glance.

Authentication.

So: login. Tokens. Guards. Interceptors. JWT strategy. Keycloak configuration.

Not particularly exciting. At least not compared to a drag-and-drop group assignment or a schedule generator.

Still, auth belongs early in this project’s journey. Because as soon as personal or club-related data comes into play, the question isn’t just:

Can the app display something?

But:

Who’s allowed to see or change which data?

The tournament app isn’t just going to be a public results list. Down the line, it manages players, tournaments, groups, results, and history.

Some of that data is harmless. Some of it is sensitive enough that you shouldn’t handle it carelessly. Especially since this is about youth tournaments, data minimization matters. But even data-minimal applications need access protection.

For the MVP, that doesn’t mean every role rule has to be perfectly modeled yet.

It does mean, though:

  • not everyone gets to manage players
  • not everyone gets to create tournaments
  • not everyone gets to record results
  • parents or spectators should eventually see, at most, a limited live view
  • secure data access needs to be prepared for

Login, then, isn’t just a convenience feature. It’s the foundation that lets later domain functionality be properly secured.

A common mistake is lumping login and permission checks together.

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to do?

This step is mostly about authentication, and about the technical groundwork for later authorization.

The user should be able to log in. The frontend should get an access token. The API gateway should validate that token. After that, an authenticated user should be available in the request context.

Roles like admin, trainer, referee, or parent are already known as a direction. But the complete authorization model comes later.

That’s deliberate.

Here too: technical vertical slice first, domain functionality second.

For this project, I’m using Keycloak as the identity provider.

There are several reasons for that.

For one, Keycloak is already familiar in my architecture lab. So I don’t have to evaluate a new auth provider first or build my own login solution. For another, Keycloak supports established standards like OpenID Connect and OAuth 2.0.

For a modern web app, the Authorization Code flow with PKCE is especially relevant. It gives the frontend a user-friendly, browser-based login without having to handle passwords itself. The backend gets a signed access token that it can validate independently.

That’s pleasant from both a developer and a user perspective:

  • users log in through a familiar login page
  • the frontend doesn’t have to store passwords
  • the backend gets standardized JWTs
  • roles and claims can be managed centrally
  • more clients can be added later

That said, Keycloak isn’t always intuitive. A new realm with new clients, scopes, and mappers gets set up quickly — but not necessarily set up correctly right away.

That’s exactly why this step deserves its own field report.

For the tournament app, this step produces the following chain:

Angular App
↓ Login via Keycloak
Access Token
↓ Bearer Token per HTTP Interceptor
Tournament API Gateway
↓ JWT Strategy / Guard
AuthenticatedUser in the request context

The frontend handles login, logout, and attaching the token to requests.

The API gateway validates the token. The frontend doesn’t decide whether a user is valid. The backend checks signature, issuer, audience, and expiration.

That matters.

A frontend can hide UI elements. But it must never be the final authority on access control.

Angular: initializing Keycloak and sending the token

Section titled “Angular: initializing Keycloak and sending the token”

In the frontend, Keycloak gets integrated into the Angular standalone app.

The high-level setup:

  • Keycloak configuration via environment or runtime config
  • initialization at app start
  • an auth guard for protected routes
  • an HTTP interceptor for API requests
  • login/logout in the app shell

The interceptor is unspectacular here, but central.

It makes sure requests to the tournament API automatically get a bearer token:

Authorization: Bearer <access-token>

That way, not every feature has to know how auth technically works. Player management, the tournament list, or tournament day should be able to focus on their own domain work later. The token mechanics stay a cross-cutting concern.

That’s exactly the point of a good skeleton: it reduces later friction.

A later feature prompt no longer has to say:

Build player management and integrate login, tokens, guards, and API auth along the way.

Just:

Build player management within the existing auth structure.

API gateway: JWT strategy, Passport, and guards

Section titled “API gateway: JWT strategy, Passport, and guards”

The API gateway is the only external backend interface for the frontend.

This is where the access token gets validated.

Technically, that happens through a JWT strategy, Passport, and guards. The guard protects routes. The strategy checks the token and derives an internal user context from it.

A simplified target model looks like this:

export interface AuthenticatedUser {
userId: string;
subject: string;
username?: string;
email?: string;
roles: string[];
}

What matters here: userId and subject come from the token’s sub claim.

Not from the email address.

Not from the display name.

Not from preferred_username.

Those values are fine to display or use for debugging. But they aren’t a stable technical identity.

For later data access, the backend needs a stable user identifier.

When a trainer creates a tournament, a referee records results, or a user is later only allowed to see their own data, the system needs a reliable reference:

created_by_user_id
updated_by_user_id
owner_user_id

Or, more neutrally:

created_by_subject
updated_by_subject
owner_subject

The exact field name matters less than where the value actually comes from.

The technical user ID should come from sub.

sub stands for subject, and in the OpenID Connect context it’s the stable identity of the authenticated user within the identity provider.

For this project, the rule is:

Technical user ID = token.sub

If sub is missing, the API gateway shouldn’t silently fall back to email or preferred_username. It should reject the request.

That’s inconvenient, but correct.

Because a missing stable identity claim isn’t a domain edge case. It’s a broken auth configuration.

There’s still no real player management, no tournament data, and no result logic.

Even so, this step is domain-relevant.

Because once data does exist later, the backend can associate it with the authenticated user.

Examples:

A trainer only sees tournaments for their own club.
A referee can only record results for an active tournament.
A parent link only sees data that's been explicitly shared for live viewing.
A user only sees data they're authorized to see.

For now, that’s still a long way off. But without a solid auth foundation, that future would get shaky very fast.

Login, then, isn’t the end of the security architecture. It’s the beginning of it.

Manual setup in the Keycloak admin console

Section titled “Manual setup in the Keycloak admin console”

Part of this step deliberately doesn’t happen in code.

The realm and clients get created in the Keycloak admin console.

For this project, that means:

Realm:
tournament
Frontend Client:
tournament-frontend
API Client:
tournament-api

The frontend client uses the Authorization Code flow with PKCE. Redirect URIs and web origins have to work for both local development and the preview environment.

The API gateway expects an access token that’s meant for the tournament API.

And that’s exactly where a typical Keycloak pitfall shows up.

After the first integration, login worked in the frontend. sub was present in the access token too.

Still, the API gateway rejected requests to /auth/me.

The guard logged, roughly:

jwt audience invalid. expected: tournament-api

So the token wasn’t invalid as such.

It was signed. It had an issuer. It had a subject. It was a genuine access token.

But it wasn’t issued for this API.

The token’s audience was:

"aud": "account"

But the API gateway expected:

tournament-api

That’s an important distinction.

A token can be valid and still not be intended for the recipient.

The fix was an audience mapper in Keycloak. The frontend client, or an assigned client scope, makes sure tournament-api ends up in the access token’s audience.

After that, the token contains, roughly:

"aud": ["account", "tournament-api"]

or, depending on configuration:

"aud": "tournament-api"

Only then does the API accept the token.

You could configure the API gateway more loosely and just drop the audience check.

That would be more convenient.

But it would be the wrong direction.

Audience answers the question:

Who is this token meant for?

If a backend accepts tokens that aren’t intended for it, the security boundary gets blurrier. Especially in a system with multiple apps, APIs, or future clients, the API gateway shouldn’t just check whether a token is valid — it should also check whether it’s meant for this API.

That’s why the audience check stays active.

The Keycloak setup gets adjusted, not the API validation watered down.

By the end of this step, the auth chain works:

Frontend Login
Access token contains sub
Access token contains aud: tournament-api
HTTP interceptor sends bearer token
API gateway validates JWT
/auth/me returns the authenticated user

That still doesn’t implement any domain role logic.

But the foundation is in place.

The API gateway now knows who’s making the request. It can evaluate roles later. It can scope data access to the authenticated user. And it can reject requests when the identity isn’t reliably established.

This step stays bounded too.

Not included:

  • no complete role matrix
  • no use-case-specific authorization checks
  • no club or tenant logic
  • no parent live-view sharing
  • no player or tournament data
  • no UI for permission management

That’s not a shortcoming. It’s intentional.

This step is meant to make auth technically sound. Domain authorization comes later, once the concrete use cases get built.

For auth integrations, it’s not enough for login to work visually.

Important review questions at this step:

  • Is the Authorization Code flow with PKCE actually used?
  • Does the configuration come from environment or runtime config?
  • Are there no hardcoded URLs?
  • Does the interceptor only send tokens to matching API targets?
  • Does the API gateway validate signature, issuer, audience, and expiration?
  • Is sub used as the stable technical user ID?
  • Is there no silent fallback to email or display name?
  • Are roles extracted from the token, but not yet over-interpreted?
  • Is /auth/me protected?
  • Does a request without a token fail?
  • Does a request with the wrong audience fail?
  • Does the setup work both locally and in preview?

This kind of review is especially worth it with Keycloak. The admin UI allows plenty of combinations that work technically but don’t match the intended authorization model.

Auth is a classic infrastructure step.

Afterward, you don’t see a tournament list. No groups. No results. No polished parent view.

Still, the step matters.

Because from now on, the system can do more than just run. It can attribute requests to a user. It can restrict data access later. It can evaluate roles. And it can prevent a valid but wrongly addressed token from just getting accepted.

Keycloak isn’t always intuitive here. New realms, client scopes, mappers, and audiences in particular are pitfalls if you haven’t set them up a few times before.

But that’s exactly why this step belongs in the journey.

Not as a polished auth tutorial.

But as a real field report:

Login isn’t done until the backend trusts the token for a good reason.