Table of contents:
- OAuth 2.0 and OIDC in three paragraphs
- Token Confusion: ID Token as Access Token
- Cross-service Relay: aud goes unchecked
- ALBeast: AWS ALB + Cognito
- DPoP: binding the token to the client
- OIDC Discovery as a map and an attack vector
- What to check during a pentest
- What’s next
JWT on its own is one thing. JWT inside an OAuth 2.0 ecosystem with a dozen microservices, three IdPs, and five token types - Access Token, ID Token, Refresh Token, Authorization Code, Logout Token - is something else entirely. Most of the attacks from articles 3-8 still apply here - a JWT is still a JWT, even if Keycloak issued it. But at the seams between components, new attacks emerge.
OAuth 2.0 and OIDC in three paragraphs
OAuth 2.0 is an authorization protocol - not authentication, authorization. It answers the question “should application X be allowed to access user Y’s resources?” The result is an Access Token that the application presents to an API. The token format isn’t defined by the spec - it can be an opaque string or a JWT.
OpenID Connect (OIDC) is a layer on top of OAuth 2.0 that adds authentication: “this is definitely user Y.” The result is an ID Token, which is always a JWT. The ID Token carries information about the user and is meant for the client application.
So you end up with two main JWT token types: Access Token (for APIs, authorization) and ID Token (for the client, authentication). Refresh Tokens can also be JWTs - Keycloak does this, as do some Auth0 configurations - and that’s a separate attack surface on its own. Access Tokens and ID Tokens are often signed by the same key from the same issuer, especially in Keycloak. Auth0, Azure AD, and Okta more commonly use separate keys, but you should verify that either way.
Token Confusion: ID Token as Access Token
The most common mistake in OIDC. An Access Token is for the API (“what permissions does this client have?”). An ID Token is for the client application (“who is this user?”). Different purposes - but both are signed JWTs from the same IdP, verifiable with the same keys.
An attacker grabs the ID Token (it’s always available to the client) and sends it to the API instead of the Access Token. If the resource server doesn’t validate the token type, it accepts it. This ID-as-AT direction is the most common. The reverse happens too - an AT gets passed off to a client application as an ID Token, and the app trusts its claims as identity data. CVE-2024-10318 (NGINX OIDC) was exactly this - a token confusion bug that allowed authentication bypass.
RFC 9068 fixes the problem: an Access Token must have typ: "at+jwt" (or the full form application/at+jwt) in its header. Check for it like this:
echo "$TOKEN" | cut -d. -f1 | tr '-_' '+/' | \
awk '{while(length%4)$0=$0"=";print}' | base64 -d 2>/dev/null \
| python3 -c "import json,sys;print(json.load(sys.stdin).get('typ'))"
If you get null or "JWT" instead of "at+jwt" - token confusion is possible. Swap in the ID Token where the Access Token goes and see what the API says.
Cross-service Relay: aud goes unchecked
Remember the aud claim from article 2? I said it was critical for security. Here’s a concrete example of why.
Microservice architecture. Shared IdP (Keycloak, Auth0, Cognito). Service A and Service B both trust tokens from that IdP. An attacker gets a token for Service A and sends it to Service B. If Service B doesn’t check aud - the token is accepted. You’ve got access to a service the token was never meant for.
Step 1: get a token for service-a (parameters depend on the IdP; Auth0 uses the non-standard audience= instead of scope):
POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=my-client&client_secret=secret&scope=service-a
HTTP/1.1 200 OK
Content-Type: application/json
{"access_token":"eyJhbGciOiJSUzI1NiIs...","token_type":"Bearer","expires_in":3600}
Step 2: send service-a’s token to service-b:
GET /api/admin HTTP/1.1
Host: service-b.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
HTTP/1.1 200 OK
Content-Type: application/json
{"role":"admin","data":"..."}
200 OK means service B isn’t checking aud. A token issued for service-a just got accepted by service-b.
CVE-2026-23552 (CVSS 9.1, Apache Camel 4.15.0-4.17.x): the KeycloakSecurityPolicy component in Apache Camel didn’t validate iss against the configured realm. A service using the Camel integration would accept tokens from a completely different realm. This wasn’t a Keycloak bug - it was a bug in how Camel was validating Keycloak’s tokens. Fixed in Camel 4.18.0.
For Keycloak specifically, check the non-standard claims realm_access and resource_access - if resource_access is checked without binding it to a specific client_id, you can register your own client in the same realm and control the roles.
ALBeast: AWS ALB + Cognito
A clever attack at the intersection of AWS services, discovered by Miggo Research in 2024. The core issue: all AWS ALBs in a region share a common JWT signing infrastructure. A token signed by any ALB in a region passes cryptographic verification on any other ALB in that same region.
An attacker creates their own Cognito User Pool - no ALB required - and gets a valid JWT. That token contains a signer field in the header: the ARN of a specific ALB. The problem: applications behind ALBs weren’t validating that field. They verified the signature (valid - shared regional keys) and checked the claims, but never confirmed that the token was actually issued by their ALB.
The fix: validate the signer field in the JWT header against your own ALB’s ARN. This isn’t the jku spoofing from article 6 - the attacker isn’t loading a key from their own URL. They’re using the legitimate, shared AWS infrastructure.
DPoP: binding the token to the client
Bearer tokens have one fundamental problem: whoever steals one can use it. Grabbed from logs, from Burp history, via XSS (article 13) - and you can use it as your own. DPoP (Demonstrating Proof-of-Possession, RFC 9449) is the mechanism designed to break that assumption.
The idea: the token is bound to the client’s cryptographic key. On every request, the client proves it holds the private key.
Here’s how it works:
- The client generates an asymmetric key pair (typically ES256)
- When requesting a token, the client creates a DPoP proof - a separate JWT with
typ: "dpop+jwt", the public key in thejwkheader, and fields forjti(unique ID for replay protection),htm(HTTP method),htu(URL), andiat(timestamp). When making requests to the resource server, aathfield is added - a hash of the Access Token - The authorization server issues an Access Token with the claim
cnf.jkt- a thumbprint of the client’s public key - On every API request, the client sends both the Access Token (in
Authorization: DPoP <token>) and a fresh DPoP proof (in theDPoPheader) - The resource server checks that the key in the proof matches the thumbprint in the Access Token
Steal the Access Token? Useless without the client’s private key. Can’t create a DPoP proof, server rejects the request.
But DPoP isn’t a silver bullet:
- Downgrade DPoP to Bearer: drop the
DPoPheader and changeAuthorization: DPoP <token>toAuthorization: Bearer <token>. If the server accepts it - there’s no binding. The simplest and most common bug - XSS in the browser: the private key is stored in the CryptoKey API as non-extractable, but an attacker can create DPoP proofs while the victim is online by monkey-patching fetch/XHR
- Pre-generation: if the server doesn’t require a nonce, you can pre-generate proofs with future timestamps
- Replay window: without a server-side nonce, a proof can be reused within a time window. RFC 9449 doesn’t specify an exact window - Auth0 gives ~120 seconds, Okta ~300 seconds. That’s not “a few seconds” - that’s minutes of real exploitability
- ath bypass: if the server doesn’t check
ath(the Access Token hash) in the proof, you can reuse a single proof with different tokens
OIDC Discovery as a map and an attack vector
/.well-known/openid-configuration is a JSON document containing the IdP’s complete configuration. For a pentester, it’s a map of everything available:
GET /.well-known/openid-configuration HTTP/1.1
Host: target.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"issuer": "https://target.example.com",
"authorization_endpoint": "https://target.example.com/authorize",
"token_endpoint": "https://target.example.com/token",
"jwks_uri": "https://target.example.com/.well-known/jwks.json",
"grant_types_supported": ["authorization_code","client_credentials","implicit"],
"token_endpoint_auth_methods_supported": ["client_secret_post","client_secret_basic"],
"code_challenge_methods_supported": ["plain","S256"],
"response_types_supported": ["code","token","id_token"]
}
What to look for:
jwks_uri- the endpoint with public keys. An algorithm confusion vector (article 4): grab the public key and use it to sign a token via HS256. Also a potential SSRF vector - if the server fetches this URL, you can point it at internal servicesgrant_types_supported- ifimplicitis listed, you get additional vectors (token in URL fragment)token_endpoint_auth_methods_supported- ifclient_secret_postorclient_secret_basicare present, look for weak client secretscode_challenge_methods_supported- if this field is missing, orplainis listed, PKCE (RFC 7636) isn’t enforced, or a downgrade is possible. PKCE is mandatory in OAuth 2.1, and missing enforcement is one of the most common findings on pentests
The discovery endpoint is both a map and an attack surface: jwks_uri, request_uri, and sector_identifier_uri can all be SSRF entry points if the server fetches them during dynamic client registration.
Mix-Up Attack (Fett, Küsters, Schmitz, 2016): the client is working with multiple IdPs. The attacker controls one of them - a malicious IdP. The user starts a flow through the malicious IdP, which redirects to a legitimate one. The user authenticates with the legitimate IdP and gets an authorization code. The client, thinking the flow was through the malicious IdP, sends the code to the malicious token endpoint. The attacker now has the code. Defense: RFC 9207 - check the iss in the authorization response so you know which IdP the flow actually went through.
Issuer Confusion: a variant of the Mix-Up Attack. A malicious Authorization Server (AS) advertises its token endpoint as being the same as the legitimate AS’s endpoint. The result is the same - the code ends up with the attacker.
What to check during a pentest
Token Confusion and Cross-service:
- Send an ID Token where an Access Token is expected (and vice versa)
- Send a token for service A to service B
- Check
typin the header - it should beat+jwtorapplication/at+jwt - Check
aud- it should be specific, not a wildcard
OAuth flow:
redirect_uri: substitution, open redirect, path traversal in the callback URL- PKCE: remove
code_challenge. If the server accepts the request without it, there’s no enforcement stateparameter: remove or replace. CSRF on the OAuth flownoncein ID Token: remove or reuse. Replay protectionscope: request elevated scope during token refresh
Provider-specific:
- Keycloak: cross-realm tokens,
resource_accesswithout binding toclient_id - AWS ALB: validate the
signerfield (ALB ARN) in the JWT header - Discovery:
jwks_uri,grant_types,code_challenge_methods_supported
DPoP:
- Replace
Authorization: DPoPwithBearer. If it’s accepted, there’s no binding - Reuse a DPoP proof with a different Access Token. Tests
athvalidation - Remove the
DPoPheader entirely
What’s next
We’ve covered how to attack JWT itself (articles 3-8 - signature forgery, articles 9-10 - crypto and JWE) and how all of that plays out in an OAuth/OIDC context (this article). But why forge a token when you can just steal one? Next up - XSS + JWT: how a single Reflected XSS turns into a full account takeover for every user on the platform. localStorage, sessionStorage, HttpOnly cookies - the threat model for each approach.