A user authenticates to a file-upload service and receives a signed JWT. That same token, unmodified and cryptographically valid, grants admin access to the Kubernetes GitOps controller. No exploit required. The signature passes. The only missing check is 3 lines of configuration.
The aud claim is the only mechanism preventing cross-service token reuse in shared-issuer architectures. It is optional under RFC 7519, requires explicit opt-in in every major library, and generates no warning when absent. That combination of design decisions shipped CVE-2023-22482 at CVSS 9.0 in ArgoCD for years.
Shared-issuer architectures create cross-service trust that signature validation does not break
In a shared-issuer architecture, a single authorization server issues tokens for all microservices using the same key. Keycloak, Auth0, and Cognito operate exactly this way by default. Any service that validates the signature accepts any token issued by that server. The key pair has no knowledge of which service the token was issued for. Every service in the trust boundary becomes a potential consumption point for every token in that boundary.
Signature and aud are orthogonal mechanisms. Signature guarantees authenticity: the token was issued by the declared party. aud guarantees that the token was issued for this specific service. Most developers enforce the first and skip the second. The signature resolves the visible problem. Absent aud produces no observable error.
The attack chain is direct. A user authenticates to Service A and receives a token with no aud claim. The attacker sends that token to Service B, an internal admin API. Service B validates the signature (passes) and validates exp (passes). The aud claim is absent, creating no validation obligation under the RFC. OWASP WSTG-SESS-10 (Testing JSON Web Tokens) documents exactly this chain. A downstream service validates the cryptographic signature but fails to assert that the token was issued specifically for it.
RFC 7519 §4.1.3: the optional claim that creates mandatory risk
RFC 7519 §4.1.3 is explicit: use of the aud claim is OPTIONAL. The rejection obligation exists only when the claim is present.
If the principal processing the claim does not identify itself with a value in the
audclaim when this claim is present, then the JWT MUST be rejected.
The decisive phrase is "when this claim is present." A token without aud is specification-compliant and accepted by any conformant validator. Absence of aud creates no rejection obligation. No exception is thrown. No warning log is written.
The asymmetry with exp reveals the design problem. The exp claim is also optional under RFC 7519 §4.1.4, but every JWT library validates it by default and warns when it is absent. Absence of exp produces an infinite-lived token, raising immediate alarms in any security review. Absence of aud produces a token accepted by every service in the ecosystem, with no alarm, no warning, and no log entry. The difference is not in the risk level: it is in the visibility each omission receives.
Every major library requires explicit opt-in and warns nothing when the check is skipped
The silence is the vulnerability. The 4 most widely adopted JWT libraries skip aud by default and emit no warning when validation is omitted. None throw an exception. None write an alert log entry. The call returns the decoded payload as if everything is correct.
// jsonwebtoken (Node.js): aud NOT validated without the audience option
jwt.verify(token, secret) // no aud check
jwt.verify(token, secret, { audience: 'service-b' }) // aud validated
jsonwebtoken exceeds 10 million weekly downloads. The overwhelming majority of production calls omit the audience parameter. No deprecation warning, no alert log.
# PyJWT: audience parameter is optional; absent = no validation, no warning
jwt.decode(token, key, algorithms=['HS256']) # no aud
jwt.decode(token, key, algorithms=['HS256'], audience='service-b') # aud validated
// java-jwt: withAudience() must be called explicitly
JWT.require(algorithm).build().verify(token) // no aud
JWT.require(algorithm).withAudience("service-b").build().verify(token) // aud validated
python-jose Issue #407 (CWE-287) documented a bug more severe than the default behavior. Even when passing audience= to jwt.decode(), the library accepted tokens that lacked the aud claim entirely. The internal logic treated absence of aud as a match for any audience value. Developers who read the documentation and configured the parameter correctly received no protection. The fix requires options={'require_aud': True} on affected versions. The bug existed silently until discovered and reported via GitHub Issues.
The common thread across all four libraries: a service that validates every other JWT claim produces no observable difference in behavior when aud is absent. No failed assertion. No elevated error rate. No security alert in the monitoring dashboard.
CVE-2023-22482: ArgoCD accepted S3 tokens as GitOps admin credentials
ArgoCD is a Kubernetes GitOps controller. Admin access means the ability to deploy any configuration to any managed cluster. A privilege escalation in ArgoCD is full control over the production environment.
CVE-2023-22482 received CVSS 9.0, Critical severity, Attack Vector: Network, Privileges Required: None. ArgoCD accepted JWTs issued for S3 authentication endpoints because both services shared the same OIDC issuer. S3 tokens typically omit the aud claim, and ArgoCD did not validate aud. Result: any valid token from the same identity provider granted ArgoCD admin access.
HackerOne Report #1889161 detailed the exploitation path. An S3 service token, unmodified, granted ArgoCD admin access and bypassed all Kubernetes RBAC. The attack required no credential theft and no brute force. Any service with access to the same identity provider had the vector available. Organizations running ArgoCD in multi-tenant OIDC environments were exposed from v1.8.2 onward.
Affected versions: v1.8.2 and later, first patched in v2.3.14, v2.4.20, v2.5.8, and v2.6.0-rc5 (GHSA-q9hr-j4rf-8fjc), covering multiple years of releases. The fix added the allowedAudiences configuration option, with the default narrowed to the client ID only. Vladimir Pouzanov of Indeed is credited with the discovery.
Kubernetes got it right: audience bound at issuance, not consumption
The Kubernetes TokenRequest API (1.20+) demonstrates the correct architecture. Audience is bound at token issuance, not delegated to each consuming service to verify. That distinction eliminates the entire bug class.
kubectl create token <service-account> --audience=<target-service> issues a token valid only for the declared audience. Projected service account volumes specify audience in the pod spec. The token delivered to the pod is valid exclusively for that audience. A token issued for kube-apiserver is rejected by Vault and S3 without any per-service configuration.
ServiceAccountNodeAudienceRestriction (introduced in Kubernetes 1.25 as alpha, GA in 1.33) goes further: the kubelet can only request tokens for audiences referenced by pods on that node. The protection is structural. It does not depend on each development team remembering to configure audience in each service. The issuer enforces the constraint at the source.
Three controls, one immediate
Control 1 (same day): add audience: 'service-name' to every jwt.verify() call. The change affects only the local service. No infrastructure change or coordinated deployment required. It is the only control that takes effect without changing the architecture.
Control 2 (structural): per-service signing keys. If Services A and B use distinct HMAC secrets or distinct asymmetric key pairs, cross-service token reuse fails at signature verification. This is independent of any aud configuration and cannot be removed by an operational mistake. The protection is guaranteed by the cryptographic system design.
Control 3 (infrastructure): validate aud at the API gateway before routing requests. One control point covers all internal services without requiring application code changes. Legacy services that cannot be modified are protected as a consequence. The gateway makes the check consistent across teams regardless of library versions.
Detection matters as much as prevention. The technique: replay each service token against every other endpoint. Any HTTP 200 response is a finding. Any 403 where a 401 was expected also warrants analysis. Automated scanners can replay tokens issued for one service against other discovered endpoints in the same architecture, identifying which services skip audience validation.
The aud claim does not protect you by existing in the token. It protects you only when every consuming service validates it. One microservice that skips the check negates the aud claims in every other service's tokens. Audit your verify() calls before auditing your issuance configuration. The issuance configuration documents intent. The verify() call in each service is the actual enforcement. Start with the service handling the most sensitive data. One verified call establishes the pattern; every other service in the codebase follows that template.
Top comments (0)