Philipp Lehmann @PhilippTheServer

Enforcing Client Identity Alongside Realm Roles

Why an OIDC realm role must be checked against the client that requested the token.

The problem

A realm in Keycloak is shared by every client registered in it: a public single-page app, an admin console, a batch service, whatever else has been added over the years. Realm roles — admin, billing-operator, support — are assigned to the user, once, for the whole realm. They are not scoped to a client.

That is by design and it is usually fine. The trouble starts when a backend decides whether to trust a request by looking only at the role claim:

roles = claims.get("realm_access", {}).get("roles", [])
if "admin" not in roles:
    raise HTTPException(403)

Decode a token issued to a public SPA for a user who happens to hold admin in the realm, and the payload looks like this:

{
  "iss": "https://auth.example.internal/realms/<your-realm>",
  "aud": "account",
  "azp": "public-spa",
  "sub": "3f1c2b7a-...",
  "realm_access": { "roles": ["admin", "offline_access"] }
}

The role is there. Nothing in realm_access.roles says which client this token was issued to, or whether that client was ever meant to talk to the admin API. If public-spa is a client the same realm also issues tokens to — for a marketing site, a status page, anything less trusted than the admin console — then any code path that can obtain a token for that user through public-spa produces a token that clears the role-only check just as well as a token from the client the API was actually built for.

This is easy to miss because it never shows up in normal testing. The admin console calls the API with a token from the admin console’s own client, the check passes, and everything looks correct. The gap only matters once a second client exists in the same realm for the same user population — which is precisely the situation a growing Keycloak deployment ends up in, one client added per application, all authenticating against the same set of realm roles.

Working through it

Where azp and aud actually come from

Keycloak populates two claims that describe the client side of the transaction. azp (authorized party) is set to the client_id that requested the token — this happens automatically, for every client, with no configuration required. aud (audience) is a different story: Keycloak’s default access token has aud set to account (or, with multiple audiences, a list that may or may not include the API you care about) unless someone adds an audience mapper to a client scope that maps a specific value into it. In other words, azp is a client identity you get for free; aud is a client identity you have to remember to configure, per client, and it is exactly the kind of setup step that gets forgotten on the third client you add and never revisited on the first two.

That asymmetry is the reason azp is the claim worth building the check around, and aud a useful addition rather than a substitute. Requiring the right audience checks that a specific mapper was configured correctly; requiring the right azp checks the one fact Keycloak asserts regardless of anyone’s mapper configuration.

Why the role check alone is not authorization

A role answers “is this user allowed to do admin things, in principle”. It says nothing about which application is asking on the user’s behalf, and OAuth2/OIDC never promised that it would — a realm role and a client are orthogonal concepts in the model. Authorization for a specific API needs both: the user must hold the role, and the token must have been issued to a client the API’s owner has decided to trust with that role. Dropping either half leaves a check that is necessary but not sufficient.

The asymmetry between clients matters here. A public SPA typically runs with no client secret and the authorization code flow plus PKCE — a reasonable choice for a browser application, but it also means the SPA’s tokens are reachable from browser-side code, extensions, and anything else running in that origin. A backend-to-backend client using the client-credentials or confidential authorization-code flow is a different trust tier. Keycloak treats both as ordinary clients in the same realm; nothing stops a user who holds admin from also being a normal user of the public SPA. The backend is the only place left to encode “role X is only meaningful when it arrived via client Y or Z”.

Building the check as an allow-list, not a blocklist

An allow-list of trusted azp values inverts the failure mode: a newly registered client is untrusted by default, and someone has to deliberately add it before its tokens are accepted for a privileged route. A blocklist has the opposite failure mode — a new client is trusted until someone remembers to exclude it — and that is the wrong default for anything gating an admin capability.

The check itself is small: decode and verify the token as normal (signature, issuer, audience, expiry), then compare claims["azp"] against a fixed set, and only after that look at the role. Order matters for the error message but not for security — both conditions are required, so either check failing means the same “no”.

Where this lives

All of this belongs in one FastAPI dependency so every route that needs it declares Depends(require_role("admin")) and gets signature verification, audience/issuer checks, the azp allow-list and the role check in one place. Duplicating any part of this per-route is how one route quietly ends up checking role only.

The solution

requirements.txt:

fastapi==0.115.6
uvicorn==0.32.1
PyJWT==2.10.1
cryptography==43.0.3
pytest==8.3.4
httpx==0.28.1

main.py:

"""FastAPI backend that authorizes on Keycloak realm role AND authorized party (azp).

A realm role alone is not enough: it is attached to the user, not to the client,
so any client in the realm that can obtain a token for a privileged user produces
a token carrying that same role. This dependency also checks which client the
token was issued for (azp) against an explicit allow-list before trusting the role.
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Callable

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient

REALM_ISSUER = os.environ.get(
    "OIDC_ISSUER", "https://auth.example.internal/realms/<your-realm>"
)
JWKS_URL = os.environ.get(
    "OIDC_JWKS_URL", f"{REALM_ISSUER}/protocol/openid-connect/certs"
)
# Requires an audience mapper on the client scope(s) used by trusted clients;
# Keycloak's default access token audience ("account") will not match this.
API_AUDIENCE = os.environ.get("OIDC_AUDIENCE", "admin-console-api")

# Clients whose tokens are trusted to carry privileged realm roles for this API.
# A client not on this list is rejected even if the user holds the required role.
TRUSTED_AZP = {"admin-console", "internal-batch-service"}
REQUIRED_ROLE = "admin"

bearer_scheme = HTTPBearer(auto_error=True)


@dataclass
class TokenVerifier:
    issuer: str
    audience: str
    trusted_azp: set[str]
    get_signing_key: Callable[[str], object]
    algorithms: tuple[str, ...] = ("RS256",)

    def verify(self, token: str) -> dict:
        try:
            signing_key = self.get_signing_key(token)
        except Exception as exc:  # JWKS lookup or key-id mismatch
            raise HTTPException(
                status.HTTP_401_UNAUTHORIZED, "Could not resolve signing key"
            ) from exc

        key = signing_key.key if hasattr(signing_key, "key") else signing_key

        try:
            claims = jwt.decode(
                token,
                key=key,
                algorithms=list(self.algorithms),
                issuer=self.issuer,
                audience=self.audience,
                options={"require": ["exp", "iat", "iss", "azp"]},
            )
        except jwt.PyJWTError as exc:
            raise HTTPException(
                status.HTTP_401_UNAUTHORIZED, f"Invalid token: {exc}"
            ) from exc

        azp = claims.get("azp")
        if azp not in self.trusted_azp:
            raise HTTPException(
                status.HTTP_403_FORBIDDEN,
                f"Client '{azp}' is not authorized to call this API",
            )

        return claims


def _default_signing_key(token: str):
    # PyJWKClient caches the JWKS response internally; no per-request fetch.
    jwks_client = PyJWKClient(JWKS_URL)
    return jwks_client.get_signing_key_from_jwt(token)


verifier = TokenVerifier(
    issuer=REALM_ISSUER,
    audience=API_AUDIENCE,
    trusted_azp=TRUSTED_AZP,
    get_signing_key=_default_signing_key,
)


def get_verifier() -> TokenVerifier:
    return verifier


def require_role(role: str):
    def dependency(
        credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
        token_verifier: TokenVerifier = Depends(get_verifier),
    ) -> dict:
        claims = token_verifier.verify(credentials.credentials)
        roles = claims.get("realm_access", {}).get("roles", [])
        if role not in roles:
            raise HTTPException(
                status.HTTP_403_FORBIDDEN, f"Missing required role '{role}'"
            )
        return claims

    return dependency


app = FastAPI(title="admin-console-api")


@app.get("/admin/ping")
def admin_ping(claims: dict = Depends(require_role(REQUIRED_ROLE))) -> dict:
    return {"status": "ok", "subject": claims["sub"], "azp": claims["azp"]}

test_main.py:

"""Unit tests for the azp + realm-role authorization dependency.

No live Keycloak instance is required: a throwaway RSA key pair signs test
tokens locally, and the app's JWKS lookup is replaced with a dependency
override that hands back the matching public key directly.
"""
from __future__ import annotations

import time

import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient

from main import API_AUDIENCE, REALM_ISSUER, TokenVerifier, app, get_verifier

_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_public_pem = _private_key.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
_private_pem = _private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
).decode()


def make_token(azp: str, roles: list[str]) -> str:
    now = int(time.time())
    claims = {
        "iss": REALM_ISSUER,
        "aud": API_AUDIENCE,
        "azp": azp,
        "sub": "3f1c2b7a-0000-0000-0000-000000000000",
        "iat": now,
        "exp": now + 300,
        "realm_access": {"roles": roles},
    }
    return jwt.encode(claims, _private_pem, algorithm="RS256")


@pytest.fixture()
def client():
    test_verifier = TokenVerifier(
        issuer=REALM_ISSUER,
        audience=API_AUDIENCE,
        trusted_azp={"admin-console", "internal-batch-service"},
        get_signing_key=lambda token: _public_pem,
    )
    app.dependency_overrides[get_verifier] = lambda: test_verifier
    yield TestClient(app)
    app.dependency_overrides.clear()


def test_right_role_wrong_azp_is_rejected(client: TestClient) -> None:
    token = make_token(azp="public-spa", roles=["admin"])
    response = client.get("/admin/ping", headers={"Authorization": f"Bearer {token}"})
    assert response.status_code == 403
    assert "not authorized" in response.json()["detail"]


def test_right_role_and_right_azp_is_accepted(client: TestClient) -> None:
    token = make_token(azp="admin-console", roles=["admin"])
    response = client.get("/admin/ping", headers={"Authorization": f"Bearer {token}"})
    assert response.status_code == 200
    body = response.json()
    assert body["azp"] == "admin-console"


def test_right_azp_wrong_role_is_rejected(client: TestClient) -> None:
    token = make_token(azp="admin-console", roles=["support"])
    response = client.get("/admin/ping", headers={"Authorization": f"Bearer {token}"})
    assert response.status_code == 403
    assert "Missing required role" in response.json()["detail"]

Run it with:

pip install -r requirements.txt
pytest -v test_main.py

The two required cases are test_right_role_wrong_azp_is_rejected and test_right_role_and_right_azp_is_accepted; the third is there because a check that only ever tests the azp allow-list would happily pass with the role check silently missing.

Conclusion

A claim being present is not the same as a claim meaning what a single route needs it to mean. realm_access.roles is a correct, faithfully-issued claim about the user; the bug is entirely in assuming it also answers a question about the client, which it was never scoped to answer.

Prefer the claim the identity provider sets automatically over the one that needs manual configuration, and use both when you can. azp costs nothing to get right because Keycloak sets it regardless of anyone’s client-scope configuration; aud is worth checking too, but only as a second signal, never as the sole gate on a capability whose correctness depends on someone having added a mapper.

An allow-list changes what “forgetting to configure something” costs you. With a default-deny list of trusted azp values, a new client that nobody has vetted yet is locked out of privileged routes by default. With a blocklist, or with no client check at all, the same oversight silently grants access.

Test the negative case as deliberately as the positive one. It is easy to write a test that proves a valid admin token is accepted and stop there; the case worth having in the suite is the token that is valid, current, and role-correct in every respect except the one claim this whole exercise exists to check.