Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

Authlib JWS Signature Bypass, Fixed in 1.9.0 (CVE-2026-96760)

Authlib up to 1.8.0 treats a JWS JSON object with empty signatures as verified without checking the key: Critical CVE-2026-96760 (CVSS 9.1), fixed in 1.9.0.

TL;DR

  • Authlib, the Python OAuth, OpenID Connect and JWT/JWS library, accepted a JWS JSON Serialization object whose signatures array is empty and returned its payload as successfully verified, without examining any signature and without ever consulting the verification key the caller passed. The flaw is CVE-2026-96760, a Critical signature-verification bypass in JsonWebSignature.deserialize_json() in authlib/jose/rfc7515/jws.py.
  • Every release up to and including 1.8.0 is affected, per the vendor advisory GHSA-6v38-4h7w-rj9h. The fix shipped in Authlib 1.9.0, released October 14, 2026.
  • The vendor advisory rates it CVSS v3.1 9.1, Critical; the CISA ADP enrichment on the CVE record rates it 9.8, Critical. The two scores differ only in availability impact.
  • CISA's SSVC assessment records Exploitation: none as of October 1, 2026, and the flaw is not in the CISA KEV catalog as of October 27, 2026. A public proof of concept is published in the advisory.
  • Upgrade to Authlib 1.9.0 or later. Until then, reject JWS JSON input whose signatures member is present but empty before it reaches Authlib, or accept only compact-serialization tokens, which are unaffected.
  • Authlib recorded 28,498,200 PyPI downloads in the last week and 107,757,780 in the last month (pypistats, checked October 27, 2026).

Am I affected?

You are affected if all of these hold:

  • A Python service installs the authlib package at 1.8.0 or older, and uses its jose module to verify JSON Web Signatures or JSON Web Tokens.
  • The service accepts general JWS JSON Serialization, the {"payload": ..., "signatures": [...]} object defined by RFC 7515 section 7.2.1, as well as or instead of dotted compact tokens.
  • Those JSON-serialized tokens reach JsonWebSignature.deserialize_json(), or JsonWebSignature.deserialize(), which forwards dict input and JSON text to deserialize_json().
  • The token source is attacker-reachable, for example a request body, a webhook, a message queue, or a header on a public API.

You are not affected in these cases:

  • You only verify compact-serialization tokens (header.payload.signature): deserialize_compact() verifies the signature unconditionally.
  • You only accept the flattened JSON form ({"payload": ..., "protected": ..., "signature": "..."}): it takes a different branch and validates its single signature.
  • You verify tokens with JsonWebToken.decode(), the jwt.decode() call most JWT code uses: it counts the dots in its input, sends a 2-dot token to deserialize_compact() (and a 4-dot one to JWE), and raises DecodeError for anything else, so it never reaches deserialize_json(). In 1.8.0 (authlib/jose/rfc7519/jwt.py), a forged {"payload": ..., "signatures": []} object has no dots and is rejected there, whereas JsonWebSignature().deserialize() accepts it.
Package Affected versions Fixed version
authlib (PyPI) all releases up to and including 1.8.0 1.9.0

Check what you have installed:

pip show authlib | grep -E "^(Name|Version)"

Check your lock files for the pinned version:

grep -iE '^authlib' requirements.txt requirements/*.txt 2>/dev/null
grep '"authlib"' Pipfile.lock 2>/dev/null
grep -A2 'name = "authlib"' uv.lock 2>/dev/null | grep -E 'name|version'
grep -iE 'authlib' pyproject.toml poetry.lock 2>/dev/null

Check whether your code uses the affected entry points:

grep -rn "deserialize_json\|deserialize(" --include="*.py" . | grep -v test

For containers, check the running container, not the image you think you built:

docker exec <container> pip show authlib | grep Version

How do I fix it?

Upgrade Authlib to 1.9.0 or later and recreate any deployed containers or server images, so the fix is in the process that runs, not only in the latest image tag.

pip install --upgrade "authlib>=1.9.0"

If you cannot upgrade yet, filter the input before it reaches Authlib. Reject any JWS JSON object whose signatures member is an empty list: that is the CVE-2026-96760 path. The filter also rejects a null member, which Authlib already refuses with a TypeError; rejecting it here only turns that crash into a clean BadSignatureError. Let objects without that member through: they are the flattened form, which Authlib already validates. Then route every deserialize() and deserialize_json() call through one wrapper that applies the filter before handing the token to deserialize():

import json
from authlib.jose import JsonWebSignature
from authlib.jose.errors import BadSignatureError

_jws = JsonWebSignature()

def _reject_empty_signatures(obj):
    if "signatures" in obj and not obj["signatures"]:
        raise BadSignatureError("JWS carries no signatures")

def safe_deserialize(data, key, decode=None):
    if isinstance(data, dict):
        _reject_empty_signatures(data)
    else:
        raw = data.encode() if isinstance(data, str) else data
        if raw.startswith(b"{") and raw.endswith(b"}"):
            _reject_empty_signatures(json.loads(raw))
    return _jws.deserialize(data, key, decode)

The wrapper mirrors how deserialize() picks a format: dicts and JSON text go through the filter, and compact tokens pass straight to Authlib, which verifies them. The limits of this workaround: it protects only the call sites you wrap, so every path that accepts general-serialization JWS needs the same check, and it does not change Authlib itself, so any future call that bypasses your wrapper is unprotected. It covers CVE-2026-96760, which is the empty-signatures path; compact and flattened inputs already fail closed inside Authlib.

What is the root cause?

JsonWebSignature.deserialize_json() in authlib/jose/rfc7515/jws.py initializes its validity flag to True before it has checked anything, and only ever sets it to False inside the loop over the token's signatures array, so a general-serialization JWS with an empty signatures list skips every verification step and is returned as successfully verified even though the caller supplied a valid key.

The vulnerable code, read from the v1.8.0 source (lines 204 onward):

headers = []
is_valid = True
for header_obj in obj["signatures"]:
    jws_header, valid = self._validate_json_jws(
        payload_segment, payload, header_obj, key
    )
    headers.append(jws_header)
    if not valid:
        is_valid = False

rv = JWSObject(headers, payload, "json")
if is_valid:
    return rv
raise BadSignatureError(rv)

Three things make this a verification bypass rather than a crash:

  • is_valid starts as True. Its default state asserts that the object is valid before any signature has been examined.
  • The loop body, the only place that can set is_valid = False, runs once per entry in obj["signatures"]. An empty list iterates zero times, so the flag keeps its initial True.
  • The caller's key is used exclusively inside _validate_json_jws(), which the empty loop never calls. The verification key the application carefully supplied is never consulted at all.

The method's contract makes the returned object authoritative: deserialize_json() signals rejection only by raising BadSignatureError, so when the call returns a JWSObject instead of raising, the caller treats its payload as verified. The entry point widens the reach: deserialize() forwards dict input and any JSON text that starts with { and ends with } to deserialize_json(), while compact tokens go to deserialize_compact(), which always runs algorithm.verify().

How is it exploited?

The attacker submits a payload wrapped in general JWS JSON Serialization with an empty signatures array, and the application returns it as verified even though the caller's key is never consulted and no signature exists anywhere in the object.

The public proof of concept in the advisory demonstrates it. The last line is added here and passes a real key, mirroring the key-supplied case in the advisory's own transcript:

import base64
from authlib.jose import JsonWebSignature

def b64(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

jws = JsonWebSignature()
payload = b'{"sub":"attacker","role":"admin"}'
forged = {"payload": b64(payload), "signatures": []}

print(jws.deserialize_json(forged, key=None))
print(jws.deserialize_json(forged, key="the-apps-real-hs256-secret"))  # still accepted

The advisory records the observed result on an installed Authlib 1.8.0 artifact under Python 3.14: the forged object is ACCEPTED with key=None and with the real key alike.

The diagram traces how the forged payload reaches application trust:

flowchart LR A["Attacker crafts claims
{sub, role, scopes}"] --> B["Base64url payload inside
general JSON serialization"] B --> C["signatures: []
no signature exists"] C --> D["deserialize_json() / deserialize()
with the app's key"] D --> E["Verification check never runs:
loop over signatures
iterates zero times"] E --> F["is_valid stays True,
payload returned as verified"] F --> G["Forged identity, roles
or authorization accepted"] style E stroke:#c00,stroke-width:3px

What can an attacker do?

An attacker can make a JWS-verifying application accept arbitrary payload content as properly signed, and the advisory and the CERT/CC note spell out what that usually means.

  • Authentication bypass: forged identity claims such as sub or a privilege claim such as role=admin pass as genuine.
  • Forged authorization: scopes, roles and permissions inside the payload become trusted.
  • Signed message injection: microservices that authenticate each other through JWS-signed messages accept attacker-written ones.
  • Integrity bypass: any data workflow that relies on JWS signatures to prove origin accepts forged content.

The attacker needs no key, no credentials, and no user interaction, which is why both published vectors give AV:N/AC:L/PR:N/UI:N. The limiting condition is on the application side: it must accept general-serialization JWS from an untrusted source and trust the verified return value.

The two published scores differ only in availability impact:

Source CVSS v3.1 score Vector Availability
Vendor advisory GHSA-6v38-4h7w-rj9h 9.1, Critical CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N None (A:N)
CISA ADP enrichment on the CVE record 9.8, Critical CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H High (A:H)

CISA's SSVC assessment of October 1, 2026 records Exploitation: none, Automatable: yes, Technical Impact: total. The same enrichment classifies the signature-verification failure as CWE-347 (Improper Verification of Cryptographic Signature).

Why does the fix work?

The fix adds one check before the loop, so an empty signatures array, the only shape that let is_valid keep its initial True, now forces it to False, and a regression test locks the behavior in. A null array never reached the verified return: iterating over None raises TypeError, before and after the fix.

The patch, commit 6c11a02d, merged in pull request #938:

         headers = []
         is_valid = True
+
+        # invalidates cases like obj["signatures"] = None or obj["signatures"] = []
+        if not obj["signatures"]:
+            is_valid = False
+
         for header_obj in obj["signatures"]:
             jws_header, valid = self._validate_json_jws(
                 payload_segment, payload, header_obj, key

The code comment also names None, but for that input the check only sets the flag: the for loop right after it still iterates None and raises TypeError, as before. The clean BadSignatureError rejection therefore applies only to the empty array, not to None.

With the check in place, an empty array forces is_valid = False, so the method falls through to raise BadSignatureError(rv) and the caller sees a rejected token. The added test, test_deserialize_json_empty_signatures, feeds {"payload": "YQ", "signatures": []} and asserts exactly that exception.

The fix is complete for this path: an attacker can no longer reach the verified return without at least one signature being validated, and the per-signature validation loop itself is unchanged, so tokens that do carry signatures are verified as before.

How do I check if I was compromised?

No indicators of compromise have been published for this flaw as of October 27, 2026, so the checks below are patterns derived from the flaw itself, not a vendor IOC list.

  • Search request bodies, token intake logs and webhook logs for the structural signature of the forged object: a JWS JSON body whose signatures member is an empty array, for example "signatures":[] or "signatures": [].
  • Review authentication and authorization logs around any accepted JWS whose claims changed with no corresponding signing activity on your side, for example an identity or role value that no legitimate issuer produced.
  • Establish your exposure window: pip show authlib on the deployed host, or docker exec <container> pip show authlib, gives the installed version, and your deployment history then shows how long a release older than 1.9.0 was in service, which bounds the window your log search must cover.

Timeline

  • 2026-09-23: CVE-2026-96760 reserved by CERT/CC (CVE record dateReserved).
  • 2026-09-28: CERT/CC publishes VU#762428 and the CVE record. The note describes versions up to and including 1.7.2 and states that the vendor could not be reached and no patch was available at the time of writing.
  • 2026-10-01: the fix is authored (commit 6c11a02d author date); the CISA ADP enrichment is recorded the same day with Exploitation: none.
  • 2026-10-14: Authlib v1.9.0 is released with the fix, in pull request #938 by Leonardo Oporto.
  • 2026-10-27: the GitHub Advisory Database publishes the reviewed entry GHSA-6v38-4h7w-rj9h at 14:50 UTC, crediting zer0d4y5, uziii2208 and nguyenminhtuan28 as reporters; this post follows.

FAQ

Does this affect ordinary compact-serialization JWTs?

No. The dotted header.payload.signature form goes through deserialize_compact(), which always calls algorithm.verify() and raises BadSignatureError on any mismatch. Only the general JSON serialization form with an empty signatures array is affected.

Does the bug only matter when the application passes no key?

No. The verification key is consumed inside _validate_json_jws(), which the empty loop never calls, so a forged token slips through even when the application correctly supplies a valid key. The exploit snippet under "How is it exploited?" calls deserialize_json() with key=None and again with a real key, and the advisory's reproduction shows the forged object accepted in both cases. Applications that pass their real key are the vulnerable population.

Is the flattened JSON form affected?

No. An object with a signature member instead of signatures takes the flattened branch, which validates its single signature and raises on failure. The bug is the general form's flag-initialization plus empty array.

Is it being exploited in the wild?

No exploitation is known. The CISA SSVC assessment of October 1, 2026 records Exploitation: none, and the CVE is not in the CISA KEV catalog as of October 27, 2026. A public proof of concept has existed since the advisory, so exposure checks should assume the technique is reproducible by anyone.

Why did CERT/CC list 1.7.2 while the advisory lists 1.8.0?

The CERT note of September 28, 2026 lists versions through 1.7.2 and records that no patch existed then. Version 1.8.0 had already shipped on August 30, 2026; the sources do not explain why the earlier note stopped at 1.7.2. The vendor advisory GHSA-6v38-4h7w-rj9h is the authoritative range: every release up to and including 1.8.0, first fixed in 1.9.0, and the advisory's own reproduction was observed on 1.8.0.

Does upgrading require any code changes?

No. The fix is internal to deserialize_json(), so pip install --upgrade "authlib>=1.9.0" and recreating deployed containers closes the flaw. Applications that keep a pre-filter workaround can remove it after the upgrade, since 1.9.0 fails closed on empty signatures.

Find the vulnerable Authlib installs before an attacker does

Run Ostorlab SCA on your repositories and container images to list every project and release that still ships Authlib below 1.9.0.

References

  • Vendor advisory GHSA-6v38-4h7w-rj9h, with the root cause, the proof of concept, the affected range up to 1.8.0 and the 1.9.0 fix: https://github.com/authlib/authlib/security/advisories/GHSA-6v38-4h7w-rj9h (checked October 27, 2026)
  • GitHub Advisory Database reviewed entry GHSA-6v38-4h7w-rj9h, Critical, CVSS v3.1 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N), published October 27, 2026, crediting zer0d4y5, uziii2208 and nguyenminhtuan28: https://github.com/advisories/GHSA-6v38-4h7w-rj9h (checked October 27, 2026)
  • CVE-2026-96760 record, CNA CERT/CC, published September 28, 2026, with the CISA ADP enrichment: CVSS v3.1 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), CWE-347 (Improper Verification of Cryptographic Signature), SSVC Exploitation: none as of October 1, 2026: https://cveawg.mitre.org/api/cve/CVE-2026-96760 (checked October 27, 2026)
  • CERT/CC Vulnerability Note VU#762428, original release September 28, 2026, describing the empty-signatures bypass, both affected entry points, the impact scenarios and the no-patch status at publication: https://www.kb.cert.org/vuls/id/762428 (checked October 27, 2026)
  • Fix commit 6c11a02d, "Fix empty signatures validation in JWS JSON deserialization", authored October 1, 2026 by Leonardo Oporto, with the added guard and the regression test: https://github.com/authlib/authlib/commit/6c11a02da8730914cea08ec195f1108a40dc6733 (checked October 27, 2026)
  • Fix pull request #938: https://github.com/authlib/authlib/pull/938 (referenced from the v1.9.0 release notes, checked October 27, 2026)
  • Authlib v1.9.0 release, published October 14, 2026, carrying the fix: https://github.com/authlib/authlib/releases/tag/v1.9.0 (checked October 27, 2026)
  • Vulnerable authlib/jose/rfc7515/jws.py at tag v1.8.0, the deserialize_json(), deserialize() and _validate_json_jws() implementations: https://raw.githubusercontent.com/authlib/authlib/v1.8.0/authlib/jose/rfc7515/jws.py (checked October 27, 2026)
  • Authlib PyPI download figures, 28,498,200 last week and 107,757,780 last month: https://pypistats.org/api/packages/authlib/recent (checked October 27, 2026)
  • CISA Known Exploited Vulnerabilities catalog, checked for CVE-2026-96760 on October 27, 2026 (not listed): https://www.cisa.gov/known-exploited-vulnerabilities-catalog