Sign-in works. Every API call returns 401.
I’ve spent the last while building a Keycloak lab — a realm defined entirely in Terraform, a .NET API behind it, and clients for React, Vue, Flutter, React Native, Electron and WinUI 3 hanging off the front.
Wiring six different clients to one identity server means making the same mistakes six times, and after a while a pattern emerged. Nearly every evening I lost to this had the identical shape: a login that worked perfectly, followed by an API that rejected every single request.
Why? Because Keycloak’s job ended the moment it issued the token. A successful sign-in proves the user authenticated — nothing more. The token’s audience, its issuer string, the shape of its role claims and its acr are each configured separately, each fails silently, and none of them surfaces until your API validates the first bearer token.
That is the family of bugs this post is about, and what makes them expensive is that nothing errors when you configure them wrong. The admin console accepts it. Terraform applies it. The login page appears, you type a password, and you land back on your app looking authenticated. Then the first API call fails, with a status code that names a symptom rather than a cause.
Here are five of them, in the order you’re most likely to hit them.
First: is it 401 or 403?
This is the fastest triage you can do, and it splits the list in half.
- 401 Unauthorized means I don’t know who you are. Your token was missing, malformed, expired, or failed validation.
- 403 Forbidden means I know exactly who you are, and you may not do this. Your token was fine.
The distinction matters because a 401 sends most SPAs into a login redirect. If you return 401 for what is really an authorization failure, you send an already-authenticated user around a login loop that cannot possibly succeed — they log in, come back, get 401, log in again. In my lab there’s a demo user called dave who exists purely to prove this: he authenticates perfectly and has no roles, so he gets 403 and a message, not a redirect.
Two commands, no tooling:
1
2
3
4
5
6
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5001/documents
# 401 - deny by default, no token
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
http://localhost:5001/documents
# 403 - token accepted, permission refused
Traps 1 and 2 below produce 401. Traps 3, 4 and 5 produce 403.
Where each one fires
Every marker in this diagram sits after the point where sign-in succeeded. That is the whole problem.
sequenceDiagram
participant B as Browser / app
participant K as Keycloak
participant A as Your API
B->>K: Authorization request (code + PKCE)
K->>B: Login page
B->>K: Credentials
K->>B: Authorization code
B->>K: Token request
K->>B: Access token
Note over B,K: Sign-in has succeeded. Everything below<br/>this line is what silently went wrong.
Note over K: 1. aud is the calling client, not your API<br/>2. iss is whatever host you asked on<br/>4. acr_values was ignored
B->>A: GET /documents (Bearer token)
Note over A: 3. Roles are nested, so RequireRole denies
A->>B: 401 or 403
Note over B: 5. The challenge header is unreadable
1. The audience mapper you didn’t know you needed
Symptom. A 401, and in the API log:
1
2
IDX10214: Audience validation failed.
See https://aka.ms/identitymodel/app-context-switches
That is the whole message. Recent versions of Microsoft.IdentityModel treat claim values as potentially sensitive and redact them by default, so it will not even tell you which audience it saw or which it wanted until you flip the context switch it links to. Decoding the token yourself is faster.
Why it looks fine. Your React app has a client in Keycloak. It signed in. It got a real, signed, unexpired token. Nothing about the login flow is wrong.
What’s actually happening. By default, the aud claim of a token is the client that requested it. So your SPA gets a token whose audience is the SPA. But your API is a different client, and it validates that aud names itself. A token minted for the front end is not a token addressed to the back end, and validation is correct to reject it.
This surprises people because it feels like the token “belongs to the user”. It doesn’t — an access token is addressed to a specific recipient, and something has to put that address on it.
The fix. An audience mapper on the calling client, adding your API to the token’s aud:
1
2
3
4
5
6
7
8
9
resource "keycloak_openid_audience_protocol_mapper" "api_audience" {
realm_id = keycloak_realm.docvault.id
client_id = keycloak_openid_client.web_react.id
name = "docvault-api-audience"
included_client_audience = keycloak_openid_client.api.client_id
add_to_access_token = true
add_to_id_token = false # the ID token is about the user, not the API
}
Every client that calls your API needs this. In my lab that’s a local.api_calling_clients list, precisely so that adding a client and forgetting the mapper isn’t possible — a new client that isn’t in the list is a client whose tokens will be rejected, and I’d rather that be one line than a mystery.
Verify. Look at the token, not the code:
1
2
3
4
5
make token | grep -A4 '"aud"'
# "aud": [
# "docvault-api",
# "account"
# ],
If aud names your calling client instead of your API, the mapper isn’t applied.
2. The issuer is whatever hostname you asked on
Symptom. A 401, and:
1
2
IDX10205: Issuer validation failed. Issuer: 'http://10.0.2.2:8080/realms/docvault'.
Did not match: validationParameters.ValidIssuer: 'http://localhost:8080/realms/docvault'
Why it looks fine. There is one Keycloak, one realm, one set of signing keys. It is not obvious that the same realm can hand out tokens with two different issuers.
What’s actually happening. Unless you pin KC_HOSTNAME, Keycloak builds the iss claim — and every URL in its discovery document — from the host in the request. Ask it on localhost, get http://localhost:8080/realms/docvault. Ask the same server on 10.0.2.2, get http://10.0.2.2:8080/realms/docvault. You can watch it happen:
1
2
3
4
5
6
curl -s localhost:8080/realms/docvault/.well-known/openid-configuration | jq -r .issuer
# http://localhost:8080/realms/docvault
curl -s -H 'Host: 10.0.2.2:8080' \
localhost:8080/realms/docvault/.well-known/openid-configuration | jq -r .issuer
# http://10.0.2.2:8080/realms/docvault
A token validator trusts exactly one issuer string, compared literally. Trailing slashes count. localhost and 127.0.0.1 are different strings.
This bites in two very different places:
Android emulators. localhost inside an emulator is the emulator, so everyone reaches for 10.0.2.2, the emulator’s alias for the host. That works for connectivity and quietly breaks your tokens. The fix is to give the device the same name, not to widen what the API trusts:
1
2
adb reverse tcp:8080 tcp:8080
adb reverse tcp:5001 tcp:5001
Now the emulator’s own localhost reaches your machine, the issuer matches, and one config file works for the simulator and the emulator alike.
Half-migrated cloud configs. This one got me. I moved my React app’s VITE_OIDC_AUTHORITY to a Keycloak deployed on Azure, and left VITE_API_BASE_URL pointing at the API on my laptop. Sign-in worked beautifully — Keycloak neither knows nor cares which API you’re going to call — and then every request 401’d against an API that trusts a different issuer. Authority and API base URL have to move together.
Fix. Match the issuer. Resist the urge to configure a list of acceptable issuers; an API should validate exactly one.
Verify. Compare two strings and make sure they’re identical:
1
curl -s "$AUTHORITY/.well-known/openid-configuration" | jq -r .issuer
3. Your roles are in the token. They’re just not where .NET looks.
Symptom. A 403 with a token that visibly contains the right role. [Authorize(Roles = "doc.editor")] denies anyway.
Why it looks fine. You decode the token, you can see doc.editor in there, so the problem “must be” the policy. It isn’t.
What’s actually happening. Keycloak nests roles inside structured claims:
1
2
3
4
5
"resource_access": {
"docvault-api": {
"roles": ["doc.reader"]
}
}
ASP.NET Core wants flat ClaimTypes.Role claims. It does not go looking inside realm_access or resource_access, so as far as the authorization system is concerned you have no roles at all — and every role check fails closed. Correctly, and silently.
The fix. A claims transformation that flattens them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity { IsAuthenticated: true } identity)
return Task.FromResult(principal);
// IClaimsTransformation runs on EVERY request - and in some hosting
// configurations more than once for the same principal instance.
if (identity.HasClaim(TransformedMarker, "true"))
return Task.FromResult(principal);
identity.AddClaim(new Claim(TransformedMarker, "true"));
AddRealmRoles(identity); // realm_access.roles
AddClientRoles(identity); // resource_access[<this api>].roles
return Task.FromResult(principal);
}
Two things worth knowing. It must be idempotent. IClaimsTransformation runs on every request, so without that marker the role claims accumulate duplicates on each pass — invisible in behaviour, but the principal grows unboundedly on a long-lived connection like SignalR. And it should import only your API’s client roles: a user may hold admin on some unrelated client, and that is emphatically not admin on yours.
Worth dropping the default-roles-<realm> composite while you’re in there. Keycloak grants it to everyone and it carries no application meaning.
While you’re here, set MapInboundClaims = false on the JWT bearer options, or .NET helpfully renames your claims and realm_access disappears entirely.
Verify. The /me-style endpoint in my lab exists for exactly this — it returns what the API thinks your roles are, which is the number that matters, rather than what the token contains.
4. acr_values that does nothing at all
This is the worst one, because it never fails. It just doesn’t work.
Symptom. None. Your step-up MFA demo runs end to end and proves nothing.
Why it looks fine. You send acr_values=silver on the authorization request. Keycloak accepts it without complaint. The user signs in. Your API reads acr from the token and it’s… not silver, but by then you’re debugging the API.
What’s actually happening. acr_values is a request for a Level of Authentication, and Keycloak needs a map from your names to LoA numbers before it means anything. Without that map, the parameter is silently ignored. No error, no warning, no log line.
The fix. One realm attribute:
1
2
3
attributes = {
"acr.loa.map" = jsonencode({ bronze = 1, silver = 2 })
}
Verify. The map is advertised in discovery, so you can confirm it took effect without signing anyone in:
1
2
3
curl -s http://localhost:8080/realms/docvault/.well-known/openid-configuration \
| jq -c .acr_values_supported
# ["bronze","silver","0","1","2"]
If your names aren’t in that array, acr_values is being ignored and any step-up you think you’re enforcing is decorative.
One more, while you’re here: add prompt=login to the step-up request. Without it the existing SSO cookie satisfies the request immediately, the user is never challenged, and you have a step-up that steps up nothing.
5. The challenge your SPA is not allowed to read
Symptom. A 403 in the browser with no way forward. The user is stuck and there’s no button to press.
Why it looks fine. This one is genuinely not your Keycloak’s fault, and it’s why I’ve put it last: it’s the last mile of trap 4. Your realm is configured correctly, your API is behaving correctly, and it is telling the client what to do:
1
2
3
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="A higher authentication level is required", acr_values="silver"
That’s RFC 9470, and it’s exactly right: re-authenticate at silver and try again.
What’s actually happening. WWW-Authenticate is not one of the CORS-safelisted response headers. A cross-origin SPA cannot read it unless the server explicitly exposes it. So the browser receives the instruction and hides it, and your front end sees a bare 403 with no idea that recovery was one redirect away.
The fix. One line in your CORS policy:
1
2
3
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.WithExposedHeaders("WWW-Authenticate"); // without this the SPA is blind
Verify. curl always sees the header, so curl will not reproduce this — you have to check from the browser, or read it back in the SPA:
1
const acr = response.headers.get('WWW-Authenticate'); // null if not exposed
I found this one with a Playwright test, which is the only reason I found it at all.
The one that does tell you
For balance: not every Keycloak trap is silent. If you’re building a desktop client and you register http://127.0.0.1:*/callback as your redirect URI, every authorization request dies with invalid_request — loudly, immediately, before any login happens.
The cause is worth knowing: Keycloak honours a wildcard only at the end of a redirect URI, so a * in the port position is compared literally. Register http://127.0.0.1/* instead; Keycloak applies RFC 8252 loopback handling and ignores the port anyway. I come back to that one properly in OAuth beyond the SPA.
Break them yourself
Reading about a silent failure is not the same as recognising one at 11pm. If you want the muscle memory, the lab is on GitHub and stands up in about a minute:
1
2
3
git clone https://github.com/MagnusJohansson/keycloak-poc
cd keycloak-poc
make up && make seed && make api
That gives you a known-good baseline — which is the one thing a search result can’t. Then go and break it on purpose. Comment out the audience mapper in infra/terraform/20-realm/mappers.tf, run make seed, and sign in: you’ll get a flawless login and a dead API. Delete the acr.loa.map attribute and watch acr_values_supported lose two entries while nothing anywhere reports an error.
Every one of these traps is a deliberate, commented line of Terraform in that repo, because each of them cost me an evening.
The triage list
When something in this family bites, four questions find it faster than reading code:
- Is the claim actually in the token? Decode it. Check
aud,iss,resource_access,exp,acr. - Do the browser and the API agree? Compare the decoded token against what a
/meendpoint reports. - Is the issuer string byte-identical?
curl "$AUTHORITY/.well-known/openid-configuration"and compare, character for character. - Has the realm drifted from the code? If your realm is in Terraform,
terraform planshould be empty. If it isn’t, someone changed something in the admin console and that’s your answer.
And log your validation failures with their reason. .NET’s JwtBearerEvents.OnAuthenticationFailed names the exact validation that failed — IDX10205, IDX10214, IDX10223 — and reading that one line beats any amount of guessing.
Earlier in this series: why your realm belongs in Terraform. Next: OAuth beyond the SPA, for native and desktop clients.
