SevyDevy

Guide · Backend

Authlib's October 2026 Vulnerability Explained: When No Signature Meant Valid

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The Authlib vulnerability of October 2026 is CVE-2026-96760, rated Critical. Authlib, an open-source Python library for OAuth (letting one app act for a user) and for signed tokens, accepted a signed message that carried no signatures at all.
  • A signature is a short code that proves who wrote a message and that nobody changed it. Authlib's check started from yes and only switched to no inside a loop. An empty list skipped the loop.
  • The fix is in Authlib 1.9.0, released on 14 October 2026. Upgrade to 1.9.1 instead, released the same day, because only 1.9.1 also refuses an empty signing secret.
  • A JWT is the familiar login token made of three dot-separated parts. Apps that only decode JWTs with Authlib's jwt.decode never reached the critical bug, because that path never uses the message format the bug lived in. They still need 1.9.1 for the key fixes.
  • Beginners can stop after the section on what to do today. Engineers then get the source diff, why scanners called 1.8.0 clean for weeks, the 1.9.0 changes that can break an OAuth server, and a default-to-no checklist.

What goes wrong when a signature check passes with no signature at all?

Your service trusts a message it should have thrown away. The Authlib vulnerability CVE-2026-96760 did exactly that: a signed message with an empty list of signatures came back as verified. No key was needed to write one.

Picture a common setup. A Python service receives settlement messages from two banking partners, and both partners sign each message. The service passes each message to Authlib and credits accounts when the check passes. The setup is made up, but the bug is real.

On Authlib 1.8.0 and older, a message whose signature list was empty passed that check. The payload inside, such as an amount or a user id, was returned as if a partner had signed it. The advisory calls the result an authentication bypass without any key material.

The same service also signs its own login tokens with a shared secret read from an environment variable. That part had two quieter bugs fixed in the same release week. This guide follows the one service through all three.

Which words do you need before reading the Authlib advisories?

Nine terms come up again and again. Each one is short to explain, and the rest of the guide uses them exactly as defined here.

Terms used in this guide
TermWhat it means here
AuthlibAn open-source Python library for building OAuth and OpenID Connect clients and servers, and for signing and checking tokens.
OAuth and OpenID ConnectStandards for letting one app act for a user (OAuth) and for proving who the user is (OpenID Connect, which adds an ID token).
JOSEA family of standards for signed and encrypted JSON. Authlib implements it in the authlib.jose module.
JWSJSON Web Signature: a payload plus one or more signatures over it. Defined in RFC 7515.
JWTJSON Web Token: a JWS whose payload is a set of claims, such as a user id and an expiry time.
Compact form vs JSON formCompact form is the familiar three parts joined by dots. JSON form is a JSON object with a payload and a list called signatures.
HMAC and HS256A signature made with one shared secret key. HS256 is HMAC with SHA-256. Anyone who knows the secret can sign.
Public keyThe half of an RSA or elliptic-curve key pair that is meant to be published. It checks signatures but cannot make them.
Fail open vs fail closedFail open means a check says yes when something unexpected happens. Fail closed means it says no.

Advisories also carry two kinds of id. A CVE id, such as CVE-2026-96760, is the global name for a bug. A GHSA id, such as GHSA-6v38-4h7w-rj9h, is GitHub's advisory id for the same bug.

What is the Authlib vulnerability of October 2026?

It is a batch of security advisories published on 14 October 2026, led by the critical CVE-2026-96760. They are fixed in Authlib 1.9.0 and 1.9.1, both uploaded to PyPI that day. Version 1.8.0, from 30 August 2026, is affected by all of the October batch.

Only the first row is critical. Rows two and three affect anyone who signs or checks HMAC (HS256) tokens, or whose verifier accepts HMAC alongside public-key algorithms. The rest matter mainly for OAuth servers and login clients.

The 14 October 2026 Authlib advisories (as of 1 November 2026)
AdvisoryWhat went wrongSeverityFixed in
GHSA-6v38-4h7w-rj9h (CVE-2026-96760)A JWS in JSON form with an empty signatures list was returned as verified.Critical, 9.11.9.0
GHSA-8m4g-j4wp-wgcg (CVE-2026-114616)An empty HMAC secret was accepted for signing and checking HS256, HS384 and HS512 tokens.High1.9.1
GHSA-7vr2-2fgp-9w7p (CVE-2026-114615)A public key with any text before its first line could still be used as an HMAC secret.High, 7.41.9.0
GHSA-c73q-w75p-j82v (CVE-2026-116672 in PyPI's advisory data; the page lists no CVE)Documented examples let one client's registration token manage another client's record.High, 8.81.9.1
GHSA-c5mf-xmp2-59rc (CVE-2026-114617)An ID token could switch off its own nonce check with a claim.Moderate1.9.0
GHSA-fch4-3c6x-j4gc (CVE-2026-114618)The JWT bearer grant accepted assertions meant for a different server.Moderate, 6.81.9.0
GHSA-x4rv-c3wx-4cr8 (CVE-2026-114620)A request_uri parameter was fetched without checks, letting outsiders make the server call internal URLs.Moderate, 5.41.9.0
GHSA-q686-2qmg-hqc7 (CVE-2026-114619)The OpenID implicit grant checked the requested scope, not the allowed scope, before issuing ID tokens.Moderate, 4.61.9.0

The 1.9.0 release notes do not mark any fix as a security fix. The critical one appears as pull request 938, described as a fix for empty signatures validation. You need the advisory pages to see the severity.

Why did an empty signatures list count as valid?

Because the check started at valid and only a failing signature could change it. In Authlib 1.8.0, deserialize_json() set a flag called is_valid to True, then looped over the signatures. With zero signatures, the loop body never ran, so nothing set the flag to False.

Think of a night guard who writes ALL CLEAR on the log sheet at the start of the shift. The guard only crosses it out after finding an unlocked door. If the guard checks zero doors, the sheet still says ALL CLEAR.

The mapping is direct. The log sheet is the is_valid flag, each door is a signature, and checking a door is verifying one signature. Where the picture breaks: a real guard would notice an empty round, but code has no such instinct.

python
# authlib/jose/rfc7515/jws.py, Authlib 1.8.0 (abridged)
is_valid = True
for header_obj in obj["signatures"]:
    jws_header, valid = self._validate_json_jws(
        payload_segment, payload, header_obj, key
    )
    if not valid:
        is_valid = False

How Authlib 1.8.0 checked a JWS in JSON form

Notice the path for an empty list: it goes from the start flag to the loop and straight to the final question, never touching the verify box.

Diagram connections:

  • JWS in JSON form arrives to Set is_valid = True
  • Set is_valid = True to Another signature left?
  • Another signature left? to Verify one signature (Yes)
  • Verify one signature to Another signature left? (Next one)
  • Another signature left? to Is is_valid still True? (No (or list empty))
  • Is is_valid still True? to Payload returned as verified (True)
  • Is is_valid still True? to BadSignatureError raised (False)
  • JWS in JSON form arrives: Payload plus a list called signatures
  • Set is_valid = True: The answer starts as yes
  • Another signature left?: Loop over the signatures list
  • Verify one signature: A bad one sets is_valid = False
  • Is is_valid still True?: Checked after the loop ends
  • Payload returned as verified: Your code trusts it
  • BadSignatureError raised: Your code rejects it

It checks the list. We downloaded the 1.8.0 and 1.9.0 source packages from PyPI and compared them. Version 1.9.0 adds one check before the loop: if the signatures list is empty, is_valid becomes False.

python
# authlib/jose/rfc7515/jws.py, Authlib 1.9.0 and 1.9.1 (abridged)
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"]:
    ...

The standard was clear all along. RFC 7515, section 5.2, says at least one signature must validate, or the JWS must be considered invalid. The old loop only enforced the opposite rule: no signature may fail.

Who is affected by CVE-2026-96760, and who is not?

You are affected if your code, or a library you use, passes untrusted input to Authlib's JsonWebSignature.deserialize_json() on version 1.8.0 or older. The generic deserialize() method is affected too. It hands any dict, or any text wrapped in curly braces, to the same JSON code.

You are not reached by this one bug if you only decode ordinary JWTs with authlib.jose.jwt.decode(). In the 1.8.0 source, that method splits the token on dots. It sends three-part tokens to the compact-form check and five-part tokens to decryption, never to the JSON form.

Authlib's own OAuth and OpenID code does not call the JSON path either. In the 1.8.0 source, the only callers of JWS deserialization outside jws.py are the compact-form calls in jwt.py.

  • Reached: services that accept JWS in JSON form, such as messages signed by several parties, and call deserialize_json() or deserialize() on them.
  • Not reached by CVE-2026-96760: login or API tokens in the usual three-part form checked with jwt.decode(). These still need 1.9.1 for the key bugs below.
  • Unknown until you look: other libraries that import authlib.jose and parse JSON-form messages for you. Search your dependency tree, as shown next.

The advisory itself names no affected downstream product. It says any relying party that calls this method on input it did not create can accept an attacker-written payload.

What should you do today about the Authlib vulnerability?

Find every place Authlib is installed, upgrade it to 1.9.1 or later, and prove the upgrade landed. The commands below were run on 1 November 2026 with pip 26.2.2 on Python 3.13.

  1. Check the installed version

    Run pip show authlib in the same environment your service uses. Any Version line below 1.9.1 needs action. The Required-by line lists installed packages that depend on Authlib.

  2. Find who pulls Authlib in

    Run pip install pipdeptree, then pipdeptree -r -p authlib. It prints Authlib first, then each package that requires it, with the version range that package asks for.

  3. Upgrade

    Run pip install --upgrade "authlib>=1.9.1". Then raise the pin in requirements.txt, pyproject.toml or your lock file, or the next build will reinstall the old version.

  4. Watch for pin conflicts

    If another package pins an older Authlib exactly, pip still upgrades but prints a dependency conflict error. Run pip check, which names the package and its pin, then upgrade or replace that package.

  5. Scan the result

    Run pip install pip-audit, then pip-audit. On 1 November 2026 it reported no known Authlib vulnerabilities for 1.9.1, two for 1.9.0 and eight for 1.8.0.

  6. Check what you already accepted

    If you ran 1.8.0 or older and accepted signed JSON messages from outside, search stored messages and logs. Look for a signatures value that is empty or is not a non-empty list. Treat those payloads as untrusted.

  7. Repeat inside every image

    Run the same checks in each container image and server that serves traffic. A laptop check only proves the laptop is fixed.

What pip-audit 2.10.1 reported for each Authlib version (run on 1 November 2026)
Authlib versionFindingsWhat they point to
1.8.08CVE-2026-96760 plus the seven sibling advisories; six list 1.9.0 as the fix and two list 1.9.1
1.9.02The empty HMAC key bug and the client registration bug, both fixed in 1.9.1
1.9.10No known vulnerabilities found

The same tool might have given a different answer a month earlier. The next engineer sections explain why scanners called 1.8.0 clean for weeks.

Why should you upgrade to Authlib 1.9.1 and not stop at 1.9.0?

Because the empty-key fix and the client registration fix only arrived in 1.9.1, later the same day. We compared the two source packages. The two code changes that matter here are a new empty-key test and a new ownership check for registration tokens.

How an empty HMAC secret lets anyone sign tokens

HMAC signing mixes a secret key into a hash of the message. Its safety rests on the secret being unknown. An empty secret is known to everyone, so anyone can produce a signature your service will accept.

Nobody chooses an empty secret on purpose. The advisory names how it happens: an unset environment variable that defaults to an empty string, an empty database column, or a key lookup that returns empty on a miss. That is our running service's risk.

We asked each version to import an empty key with OctKey.import_key(b""), the step Authlib uses to load HMAC secrets. This is a configuration check; no token was made.

OctKey.import_key with an empty key (run on 1 November 2026)
Authlib versionEmpty bytesEmpty string
1.8.0AcceptedAccepted
1.9.0AcceptedAccepted
1.9.1ValueError: oct key material must not be emptyValueError: oct key material must not be empty

On 1.9.1, calling jwt.encode() with an empty secret now fails with the same ValueError. If your service starts throwing that error after the upgrade, the upgrade did its job. Fix the missing secret; do not catch the error.

The registration token fix

Authlib's RFC 7592 endpoint lets a registered OAuth client read or change its own record. Its documented examples never checked that the presented token belonged to the client being changed. In 1.9.1 the endpoint itself calls token.check_client(client) and denies the request otherwise.

If you run that endpoint, your token class must implement check_client after the upgrade; the SQLAlchemy token mixin already does. If you do not run an OAuth authorisation server, this one does not touch you.

How could a public key become an HMAC secret again?

By slipping past a check that only looked at the first bytes of the key. This is GHSA-7vr2-2fgp-9w7p, which the advisory calls an incomplete fix of CVE-2024-37568. Versions 1.3.1 to 1.8.0 are affected.

The attack idea is old and well known. If a service verifies HS256 tokens using its RSA public key text as the HMAC secret, anyone with the public key can sign tokens. Public keys are, by design, public.

Authlib's guard against this refused key text that started with markers such as -----BEGIN or ssh-rsa. The advisory points out that the cryptography library skips any text before the BEGIN line when loading a key. So a stored key with a leading comment, space or byte-order mark passed the guard.

The 1.9.0 diff changes one idea: the guard now searches for those markers anywhere in the key, not only at the start. It also runs the same test when a stored key is used, not only when it is imported.

python
# authlib/jose/rfc7518/oct_key.py
# Authlib 1.8.0
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
    raise ValueError("This key may not be safe to import")

# Authlib 1.9.0
if any(marker in raw_key for marker in POSSIBLE_UNSAFE_KEYS):
    raise ValueError("This key may not be safe to import")

The deeper fix is yours to make: decide which algorithms your service accepts, in code. Authlib's ready-made authlib.jose.jwt object accepts HMAC and public-key algorithms together. That mix is what makes key confusion possible.

python
import os

from authlib.jose import JsonWebKey, JsonWebToken

# Accept RS256 only. Any other "alg" is refused before key handling.
verifier = JsonWebToken(["RS256"])

private_key = JsonWebKey.generate_key("RSA", 2048, is_private=True)
public_key = private_key.as_dict(is_private=False)

token = verifier.encode({"alg": "RS256"}, {"sub": "user-1"}, private_key)
print(verifier.decode(token, public_key)["sub"])

hs_token = JsonWebToken(["HS256"]).encode({"alg": "HS256"}, {"sub": "user-1"}, os.urandom(32))
try:
    verifier.decode(hs_token, public_key)
except Exception as exc:
    print(type(exc).__name__)

On both 1.8.0 and 1.9.1 this prints user-1, then UnsupportedAlgorithmError, after a deprecation warning on standard error. The HS256 token was made with a random secret, so it is an ordinary token; the pinned verifier simply refuses its algorithm.

Where each check sits when a token reaches the running service (Authlib 1.9.1)

Notice that every layer can say no on its own. The October bugs were each one layer that said yes too easily.

Diagram connections:

  • Token or signed message to Algorithm allow-list
  • Algorithm allow-list to Key import guard (Allowed alg)
  • Key import guard to Signature check (Safe key)
  • Signature check to Claim checks (Signature valid)
  • Claim checks to Your business logic (Claims match)
  • Algorithm allow-list to Rejected and logged (Other alg)
  • Key import guard to Rejected and logged (Empty or PEM key)
  • Signature check to Rejected and logged (Bad or no signature)
  • Claim checks to Rejected and logged (Wrong aud or nonce)
  • Token or signed message: From a partner or a logged-in user
  • Algorithm allow-list: Your code: JsonWebToken(["RS256"])
  • Key import guard: 1.9.1 refuses empty keys and PEM text
  • Signature check: 1.9.0: empty signature list fails
  • Claim checks: Audience, nonce, expiry; 1.9.0 tightened two
  • Your business logic: Credits the account or logs the user in
  • Rejected and logged: Any layer that says no ends here

Why did scanners call Authlib 1.8.0 safe for weeks?

Because the first public record named the wrong range. CERT/CC, a coordination centre for vulnerability reports, published note VU#762428 and CVE-2026-96760 on 28 September 2026. Both said versions 1.7.2 and below, though 1.8.0 had shipped on 30 August with the same code.

Scanners compare your installed version with the affected range in an advisory database. A range that ends at 1.7.2 makes 1.8.0 look clean. The title of the repository advisory says it plainly: 1.8.0 was vulnerable while every published record excluded it.

Timeline of CVE-2026-96760 (all dates 2026)

Notice the gap: from 28 September to 14 October, a public CVE existed but the newest release, 1.8.0, sat outside its range.

Diagram connections:

  • 21 Aug: vendor notified to 30 Aug: 1.8.0 released
  • 30 Aug: 1.8.0 released to 28 Sep: CVE published
  • 28 Sep: CVE published to 1 Oct: fix proposed
  • 1 Oct: fix proposed to 14 Oct: 1.9.0 and 1.9.1
  • 14 Oct: 1.9.0 and 1.9.1 to 27 Oct: record reviewed
  • 21 Aug: vendor notified: CERT/CC → Authlib maintainers
  • 30 Aug: 1.8.0 released: Same flawed loop, still vulnerable
  • 28 Sep: CVE published: Range says 1.7.2 and below
  • 1 Oct: fix proposed: Pull request 938 adds the empty check
  • 14 Oct: 1.9.0 and 1.9.1: Fixes ship; eight advisories published
  • 27 Oct: record reviewed: GitHub database lists 1.8.0 and below

As of 1 November 2026, the CVE record itself still reads 1.7.2 and below. It also carries a CISA score of 9.8, while GitHub's review scores the same bug 9.1. Different scoring teams often disagree; the fix is the same.

The lesson for your own process is plain. A clean scan means no known record matched, not that the code is safe. For a critical bug in a library you depend on, read the advisory and compare the code, not only the version range.

What else in Authlib 1.9.0 can break an OAuth server after upgrading?

Six of the fixes change default behaviour, mostly on the server side. Each turns a quiet yes into a clear no, so a setup that relied on the old default now fails loudly. We confirmed each change in the 1.8.0, 1.9.0 and 1.9.1 source diffs.

Authlib 1.9.0 and 1.9.1 behaviour changes worth testing before you deploy
AreaOld behaviour (1.8.0)New behaviour (1.9.0 and later)What to do
JWT bearer grant audienceAn empty get_audiences() list only checked that aud existedAn empty list raises InvalidGrantErrorReturn your token endpoint or issuer from get_audiences()
Request objects by URL (JAR)request_uri was fetched with no checksA new validate_request_uri() hook returns False by default, so every URI is refusedImplement an allow-list of approved URIs per client
ID token nonceA nonce_supported claim set to false skipped the nonce checkThe claim no longer changes anything; the nonce your app passes is checkedAlways pass the nonce you stored at login
OpenID implicit grant scopeChecked the scope the client asked forChecks the scope the client is allowedGrant openid only to clients that need ID tokens
Signing secrets (1.9.1)Empty secret acceptedValueError at import or signingFail start-up when the secret is missing
Client configuration endpoint (1.9.1)Token ownership not checkedCalls token.check_client(client); the base TokenMixin raises NotImplementedErrorImplement check_client on your token model

Version 1.9.1 also requires joserfc 1.6.8 or newer, up from 1.6.1. joserfc is the newer JOSE library from the same author that Authlib now depends on. If your requirements pin joserfc below 1.6.8, pip stops with a ResolutionImpossible error.

One more message you will see: importing authlib.jose prints an AuthlibDeprecationWarning that tells you to use joserfc instead. It appeared on 1.8.0 and 1.9.1 in our runs and points to version 2.0.0. It is a warning, not an error.

What is the general lesson behind the Authlib advisories?

Security checks must fail closed: start at no, and require positive proof for yes. Four of the October bugs were defaults that said yes when something was missing: no signatures, no key, no audience list or no allowed URI list.

  • Start every validity flag at False, or count passes and require at least one. Never let an empty loop mean success.
  • Reject empty and short secrets at start-up. For HS256 that means at least 32 random bytes.
  • Pin the algorithms you accept in code. Never let the token's own header choose between HMAC and public-key checks.
  • Never let the thing being checked switch off the check, as the nonce_supported claim did.
  • Test the empty case on purpose: empty lists, empty strings and missing settings, for every check you own.

For the running service, that means three code changes beyond the upgrade. It refuses to start without a 32-byte secret, it pins its token algorithm, and its tests now include an empty input for every check.

How do you check that the Authlib fix is really in place?

Check the version in the environment that serves traffic, then confirm the 1.9.1 empty-key guard. Expect these exact outputs.

bash
pip show authlib | grep -E "^(Version|Required-by)"
# expect: Version: 1.9.1 (or later)

python -c "from authlib.consts import version; print(version)"
# expect: 1.9.1

python -c "from authlib.jose import OctKey; OctKey.import_key(b'')"
# every version first prints an AuthlibDeprecationWarning
# expect on 1.9.1: ValueError: oct key material must not be empty
# on 1.9.0 or older: only the warning, because the empty key is accepted

grep -n 'if not obj\["signatures"\]' "$(python -c 'import authlib, os; print(os.path.dirname(authlib.__file__))')/jose/rfc7515/jws.py"
# expect one matching line on 1.9.0 or later; nothing on 1.8.0

The grep confirms the critical fix is present in the installed files without sending any test message. In containers, run the same commands with docker exec in each running container.

How do you explain the Authlib vulnerability in a system design interview?

Use it as a case study in fail-closed validation and defence in depth for token checks.

  1. Name the component

    A token or signed-message verifier sits at the trust boundary. Everything after it believes the claims it returns.

  2. Name the failure

    Authlib 1.8.0 started a validity flag at True and only cleared it inside a loop. An empty signature list skipped the loop and passed.

  3. Name the siblings

    Empty HMAC secrets and public keys used as HMAC secrets let outsiders sign tokens. Both are key-handling failures, not crypto failures.

  4. Give the fix

    Upgrade to 1.9.1, pin algorithms per verifier, reject empty and short keys at start-up and require at least one valid signature.

  5. Add operations

    Scanners missed 1.8.0 because the CVE range was wrong. Read critical advisories directly and re-scan after records change.

  6. Close with the principle

    Every check should default to deny, and every layer should be able to reject on its own.

Questions people ask

Is Authlib 1.8.0 vulnerable to CVE-2026-96760?
Yes. The GitHub-reviewed advisory GHSA-6v38-4h7w-rj9h lists 1.8.0 and earlier as affected and 1.9.0 as patched. The original CVE record said 1.7.2 and below, which is why some scanners called 1.8.0 clean for weeks. We confirmed that 1.8.0 still had the flawed loop by reading its source package.
Is Authlib 1.9.0 enough, or do I need 1.9.1?
Use 1.9.1. Version 1.9.0 fixes the critical empty-signatures bug, but 1.9.1 adds the empty HMAC key check and the client registration check. On 1 November 2026, pip-audit reported two open advisories for 1.9.0 and none for 1.9.1. Both were released on 14 October 2026.
My app only uses Authlib to log users in through an outside identity provider. Am I affected?
Probably not by the critical bug, which needs JWS messages in JSON form. A login client still parses ID tokens, and the nonce fix in 1.9.0 applies to implicit and hybrid flows and to direct parse_id_token() calls. Upgrading to 1.9.1 is a small change, so do it rather than reason about each path.
Can I work around CVE-2026-96760 without upgrading?
Only partly. The advisory's proposed fix rejects an empty signatures list before the loop. In your own code, reject the message unless signatures is a non-empty list, before calling deserialize_json() or deserialize(). That covers your own call sites, not libraries that call Authlib for you. Treat it as a stopgap for days, not a fix, and plan the upgrade.
Do I need to rotate keys or secrets after upgrading?
Rotate if exposure was possible. If your service ever ran with an empty HMAC secret, every token it accepted could have been forged, so set a strong secret and invalidate existing sessions. If a public key could have worked as an HMAC secret, invalidate tokens accepted in that period and add an algorithm allow-list. Otherwise these advisories do not require rotation.
Why did the Authlib release notes not say these were security fixes?
The 1.9.0 notes list each change by pull request without a security label. The critical fix appears as a fix for empty signatures validation. Severity, CVE ids and affected ranges are on the GitHub advisory pages, so read those, not only the changelog, when you judge urgency.
Should I move from authlib.jose to joserfc?
Plan for it. Authlib prints a deprecation warning when you import authlib.jose, saying it stays compatible until version 2.0.0. joserfc is the newer library from the same author, and Authlib 1.9.1 already depends on it. In our run, joserfc 1.7.7 also rejected an empty HMAC key.

Sources and further reading

Practise this with Lala

Practise