Keycloak Is Not Your Zero Trust Architecture
Where Keycloak's job ends and your APIs' job begins: two Go APIs, eleven requests that should succeed or fail, and the results from a real run.
Your applications use Keycloak. Users sign in with MFA. Every API receives a signed access token. The services run on a private network.
Is that a zero trust architecture?
Not by itself. A more useful question is: if one of those services is compromised, what can it do with the credentials it receives?
In Keycloak Token Exchange, Hands-On, order-api exchanged Alice’s token for one addressed to a single downstream API. That post ended where Keycloak hands the token over. This one follows the token to the APIs that receive it. I built two of them, wrote down which requests they must accept and which they must reject, and sent them eleven requests. Every token, status code and error message below comes from that lab.
Tested with Keycloak 26.8.1, the Terraform provider keycloak/keycloak 5.10.0, Go 1.27, golang-jwt v5.3.1 and keyfunc v3.8.2.
“Internal” is a location, not a permission#
NIST SP 800-207 describes zero trust as granting no implicit trust based solely on network location or asset ownership. A request isn’t authorized because it came from your cluster, your VPN or a company laptop.
Network controls still help: limiting which workloads can reach an API shrinks what an attacker can try. They answer a different question than whether a particular request may read an order or create a payment. For service-to-service calls, I separate three questions:
- Can this workload reach the service? Network and transport controls.
- Is this credential acceptable here? Token validation and, where you use it, proof of possession.
- May this identity perform this operation on this resource? Authorization.
Each answer stands on its own. Running in the same Kubernetes namespace is no permission to charge a customer, and neither is a token signed by the right realm. This post covers questions 2 and 3. Device posture, workload identity and the rest of a zero trust architecture need controls of their own.
The shop and the threat#
The shop is the one from the token exchange post, with two changes: inventory-api and payment-api now run as real services, and there’s a second user, Bob.
shop-webis the app Alice and Bob sign in to.order-apihandles orders and calls the other APIs. It’s the requester that exchanges tokens.inventory-apiserves stock levels and requires its client rolestock:read.payment-apipays orders and requires its client rolepayment:create.
Alice may read stock and create payments. Bob may only read stock. payment-api knows two orders: 42 belongs to Alice, 43 to Bob.
The threat is deliberately narrow: an attacker controls inventory-api and reads every bearer token sent to it. They can reach payment-api over the network, but haven’t compromised order-api, Keycloak or their credentials. Whatever they take from inventory traffic must be useless at payment-api.
One token per downstream call#
Step 3 of the token exchange post showed the alternative: exchange without audience, and order-api gets a token with "aud": ["inventory-api", "payment-api"] and the roles of both. Send that to inventory, and the attacker holds a credential payment-api accepts. Its audience check even passes, because payment-api really is an audience. The mistake happened at the exchange.
So order-api exchanges once per downstream call, each time with the audience that call needs:
sequenceDiagram
participant Order as order-api
participant KC as Keycloak
participant Inv as inventory-api
participant Pay as payment-api
Order->>KC: Exchange subject token<br/>audience=inventory-api
KC-->>Order: Token for inventory-api
Order->>Inv: Stock request + inventory token
Note over Inv: Assume this service is compromised
Inv->>Pay: Attempt to reuse inventory token
Pay-->>Inv: Reject: wrong audience
Order->>KC: Exchange subject token<br/>audience=payment-api
KC-->>Order: Token for payment-api
Order->>Pay: Payment request + payment token
Note over Pay: Validate token and authorize paymentThis is the token inventory-api received in the lab run, decoded:
{
"iss": "http://localhost:8080/realms/demo",
"azp": "order-api",
"aud": "inventory-api",
"preferred_username": "alice",
"resource_access": {
"inventory-api": {
"roles": ["stock:read"]
}
},
"iat": 1793654037,
"exp": 1793654067
}
One audience, one role, and 30 seconds of validity. Exchanged tokens are issued to order-api, and in the lab its access token lifespan is 30 seconds instead of the realm’s 5 minutes.
What Keycloak enforces#
Keycloak decides whether order-api may exchange at all, and what the new token contains:
order-apiis a confidential client with the Standard Token Exchange switch on, and it authenticates at the token endpoint.- The subject token must name
order-apiin itsaud, unless it was issued toorder-apiitself. - Full scope allowed is off, and role scope mappings limit
order-apitostock:readandpayment:create. The Audience Resolve mapper in Keycloak’s built-inrolesclient scope turns those roles into audiences. - The
audienceparameter then filters the audiences and the client roles: the token keeps only the roles of the requested clients.
That makes two separate decisions. The configuration decides what order-api can obtain at all; each request decides what one call needs. Both matter, because a compromised order-api can simply ask for payment-api.
Audience Resolve ties audiences to roles, but it isn’t the only way into aud. An Audience mapper with a hardcoded client adds that audience whatever roles the user has. The lab uses one on purpose, for row 6 of the matrix.
Two APIs that check everything#
Both APIs run the same Go code; an environment variable makes it inventory-api or payment-api. It validates tokens with golang-jwt and fetches the realm’s public keys with keyfunc. The parser options carry most of the policy (auth.go):
parser: jwt.NewParser(
jwt.WithValidMethods([]string{"RS256"}), // the realm's key algorithm, nothing else
jwt.WithIssuer(cfg.Issuer), // from configuration, never from the token
jwt.WithAudience(cfg.Name), // this API; a missing aud fails too
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithLeeway(cfg.Leeway),
),
Two Keycloak-specific checks follow the signature and the standard claims:
// Keycloak sets typ "Bearer" in access tokens and "ID" in ID tokens.
if claims.Type != "Bearer" {
return accessClaims{}, invalidToken(fmt.Sprintf("not an access token (typ %q)", claims.Type))
}
if len(v.allowedAZP) > 0 && !slices.Contains(v.allowedAZP, claims.AuthorizedParty) {
return accessClaims{}, invalidToken(fmt.Sprintf("token was requested by %q, which may not call this API", claims.AuthorizedParty))
}
Next comes the API’s own client role from resource_access. payment-api then asks what no token can answer: does the order belong to the caller, and is it still unpaid? The amount comes from the order. The request body carries only order_id, and any other field is rejected.
Every route goes through the same wrapper that runs these checks, so no internal path skips them. If a gateway validates tokens too, the service still needs the checks on every path around the gateway.
Three details are easy to get wrong:
- The issuer and the key URL are configured, never read from the token. In the lab they even point to different hosts: tokens are requested from your machine, so
issishttp://localhost:8080/realms/demo, while the APIs fetch keys fromkeycloak:8080inside the Docker network. audcan be a string or an array. Keycloak writes a single audience as a string, as in the token above. The library accepts both.- Accepting only RS256 stops tokens signed with
HS256ornone. The unit tests cover both, plus a token signed by a foreign key under the realm’s key ID.
Each rejection names the check that failed, so the matrix can show why. The APIs log one JSON line per request with a request ID, and never the Authorization header:
{"time":"2026-11-02T21:13:57.658327965Z","level":"INFO","msg":"request","api":"payment-api","request_id":"c62fbfa9acd6fb9d","method":"POST","path":"/payments","status":401,"reason":"token has invalid claims: token has invalid audience"}
In production I’d tell the caller less than the lab does and keep the details in the log.
The acceptance matrix#
matrix.sh gets the tokens from Keycloak, sends eleven requests and compares each status with the expected one. This is the output of a run from a clean start:
# Request Want Got
1 No token, to inventory-api 401 401 ok
no bearer token
2 Alice's login token (aud order-api) 401 401 ok
token has invalid claims: token has invalid audience
3 Alice's login token, no order-api scope 401 401 ok
token has invalid claims: token is missing required claim: aud claim is required
4 Inventory token, to inventory-api 200 200 ok
SKU-1001: 7 in stock
5 Inventory token, replayed at payment-api 401 401 ok
token has invalid claims: token has invalid audience
6 Bob: payment-api aud, no role, his order 43 403 403 ok
token lacks the payment-api role payment:create
7 Alice's payment token, her order 42 201 201 ok
pay-42: EUR 129.90
8 Alice's payment token, order 42 again 409 409 ok
order 42: order is already paid
9 Alice's payment token, Bob's order 43 403 403 ok
order 43: order belongs to another user
10 partner realm token (aud payment-api) 401 401 ok
token is unverifiable: error while executing keyfunc: key not found "0RBhx-CGMUIser8ykhTOC159pt1WDZAFWvR39wPHcPo"; failed keyfunc: could not read JWK from storage
(waiting 36s until the inventory token is past exp + 5s leeway)
11 Inventory token after it expired 401 401 ok
token has invalid claims: token is expired
All 11 requests got the expected status.
The rows worth a closer look:
Row 5 is the attack. The attacker replays Alice’s inventory token at payment-api. The signature is valid, the issuer trusted, the token unexpired, and payment-api rejects it for the same reason as row 2: wrong audience. Row 3 is the quieter variant, a token with no aud at all. golang-jwt rejects it because an expected audience is configured. Many libraries check aud only when you configure one, so configure it and test the empty case.
Row 6 isolates the role check. To prove that payment-api checks payment:create by itself, I needed a token that passes the audience check without the role. Bob’s token got the payment-api audience from an optional client scope with a hardcoded Audience mapper, a test fixture in the lab:
{
"iss": "http://localhost:8080/realms/demo",
"azp": "order-api",
"aud": "payment-api",
"preferred_username": "bob",
"resource_access": {}
}
The audience check passes, and the role check answers 403. Test the role check with a token for the wrong audience instead, and the audience check rejects it first: you never learn whether the role check works.
Rows 7 to 9 go beyond roles. Alice has payment:create, so her token passes every token check. payment-api still pays only her own order, only once, and refuses Bob’s order 43. Many production APIs answer 404 for another user’s resource, so callers can’t find out which orders exist. The lab answers 403 to make the reason visible.
Row 10 comes from the same Keycloak server. A second realm’s partner-app gets tokens that say aud: payment-api, correctly signed by the same server:
{
"iss": "http://localhost:8080/realms/partner",
"azp": "partner-app",
"aud": ["payment-api", "account"]
}
payment-api loads keys only from the demo realm, so the token’s key ID is unknown and its signature can’t be verified. An API that chose its key source from the token’s iss would have fetched the partner realm’s keys and found a valid signature. Only a pinned issuer would then stand between that token and a payment.
Row 11 shows the leeway. The token expired after 30 seconds, and the API tolerates 5 more seconds of clock skew, so the script waits until both have passed.
azp names the requester, not the presenter#
The stolen token from row 5 says "azp": "order-api", which is exactly what payment-api’s allow-list permits. That check would have let it through; only the audience stopped the replay.
An azp allow-list narrows which clients’ tokens an API accepts. It can’t prove that the current HTTP caller is that client, because a bearer token works for whoever holds it. If you need proof that the presenter holds a key, use sender-constrained tokens: certificate-bound tokens over mutual TLS (RFC 8705) or DPoP (RFC 9449), with the resource server enforcing the binding. Strong client authentication at the token endpoint, such as a signed JWT, protects the exchange, not the token that comes out of it.
When access changes#
An API that validates JWTs locally sees the claims as they were at issuance. Remove Alice’s role or end her session, and her unexpired tokens keep working.
The Keycloak documentation is specific about exchanged tokens: revoking the subject token doesn’t revoke the access tokens already exchanged from it. Keycloak follows the revocation chain only for exchanged refresh tokens. The lab’s exchanges return access tokens, so they stay valid until exp.
So for each high-impact operation, ask: how long after access is removed can this request still succeed? In the lab, row 11 gives the answer: up to 30 seconds of lifespan plus 5 seconds of leeway. Where that window is too long, introspection or an online policy decision gives fresher answers, at the price of latency and a runtime dependency on Keycloak.
What a compromised inventory-api can still do#
With these checks in place, the token stolen from inventory traffic is useless at payment-api. That’s a real improvement, but the attacker who controls inventory-api can still:
- Read the data and secrets that workload can access.
- Use the stolen inventory tokens at
inventory-apiuntil they expire. - Return false stock levels to
order-api. - Exploit any other permission or vulnerability within reach.
Which service is compromised matters, too. order-api receives user tokens and holds the credentials to exchange them, so it can obtain a payment token for every user whose token passes through it and who has payment:create. Compromising it is a different threat than compromising inventory, and it’s why workload isolation, secret handling, network restrictions and monitoring stay on the list.
Wrapping up#
I wouldn’t say this shop has “achieved zero trust” because it uses Keycloak and token exchange. The result is narrower, and it’s measured: a token stolen from inventory-api doesn’t work at payment-api, a role doesn’t replace the question of whose order it is, and the lab shows which check answers each request.
That’s a better starting point than asking which product makes an architecture zero trust. Pick a boundary, write down the request that must fail, and run it. The lab needs only Docker, curl and jq; clean up with docker compose --profile tools down -v.
References#
- NIST SP 800-207: Zero Trust Architecture
- NIST SP 800-207A: A Zero Trust Architecture Model for Access Control in Cloud-Native Applications in Multi-Cloud Environments
- Keycloak: Using token exchange
- RFC 8693: OAuth 2.0 Token Exchange
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
- RFC 8725: JSON Web Token Best Current Practices
- RFC 6750: OAuth 2.0 Bearer Token Usage
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)