CVE-2026-23993: JWT authentication bypass in HarbourJwt via “unknown alg”

Published: 21 Jan 2026

I didn't know Harbour even existed as a language when I found this bug. The fun part is that I also didn't need to know Harbour to spot a critical flaw: I used an LLM as a first-pass reviewer, then validated the finding by reading the small, security-critical code paths myself.

This post covers CVE-2026-23993, an authentication bypass in HarbourJwt where any unrecognized JWT algorithm value in the header causes signature verification to be bypassed.

How I found it (without knowing Harbour)

I scanned a bunch of JWT libraries listed on jwt.io and ran an internal “jwt-library-review” skill (using Claude) on each repository. The goal wasn’t to blindly trust the output, it was to triage: quickly flag suspicious patterns so I could focus my manual review where it matters.

#!/bin/bash

for lib in ~/jwt/*/*/; do
    echo ""
    echo "=== $lib ==="
    if [[ -f "${lib}JWT_SECURITY_REVIEW.md" ]]; then
        echo "Skipping: JWT_SECURITY_REVIEW.md already present"
        continue
    fi
    echo "Please run the jwt-library-review skill on the code in $lib"
    claude -p "Please run the jwt-library-review skill on the code in $lib"
done

echo "Done!"

Yes, even if I'm speaking to an AI, it doesn't hurt to be polite (also this shell script was generated by Claude).

One repository stood out because the signature verification logic was too permissive around the algorithm selection. That's where this issue came from.

Summary

HarbourJwt incorrectly accepts forged tokens when the JWT header contains an algorithm value not in HS256, HS384, or HS512.

  • Not just the classic "alg":"none" issue.
  • Any unknown value works: none, None, NONE, foo, zzz, an empty string, etc.
  • Impact: complete authentication bypass - forge tokens without the secret key.
Where the bug lives

The vulnerable logic is in Verify() (in src/jwt.prg). The bypass happens because signature generation returns an empty string for unknown algorithms, and verification compares strings without treating the algorithm error as fatal.

Root cause: “unknown algorithm” returns an empty signature

In HarbourJwt, signature computation is performed by GetSignature(). When the algorithm is not recognized, the method sets an error string but returns an empty signature ("").

METHOD GetSignature( cHeader, cPayload, cSecret, cAlgorithm ) CLASS JWT
  LOCAL cSignature := ""

  DO CASE
     CASE cAlgorithm=="HS256"
         cSignature := ::Base64UrlEncode( ... HB_HMAC_SHA256(...) )
     CASE cAlgorithm=="HS384"
         cSignature := ::Base64UrlEncode( ... HB_HMAC_SHA384(...) )
     CASE cAlgorithm=="HS512"
         cSignature := ::Base64UrlEncode( ... HB_HMAC_SHA512(...) )
     OTHERWISE
         ::cError := "INVALID ALGORITHM"
         // cSignature remains "" (empty string)
  ENDCASE

RETU cSignature

Returning an empty signature for an unknown algorithm is already dangerous - but the real issue is what happens next.

The verification bypass

During verification, HarbourJwt computes a new signature and compares it to the token signature:

cNewSignature := ::GetSignature( aJWT[1], aJWT[2], ::cSecret, aHeader[ 'alg' ] )

IF ( cSignature != cNewSignature )
  ::cError := "Invalid signature"
  RETU .F.
ENDIF

If an attacker supplies a token with:

  • alg set to an unsupported value (so GetSignature() returns "")
  • an empty JWT signature segment (so the token signature is also "")

Then both strings match:

  • cSignature (from token) = ""
  • cNewSignature (computed) = ""

The comparison passes, the IF block is skipped, and verification continues as if the token was valid. The error message ("INVALID ALGORITHM") is set, but GetSignature() still returns the empty string as a valid result rather than signaling failure. The caller never checks ::cError before proceeding.

Exploit sketch

JWTs are three dot-separated segments: base64url(header).base64url(payload).base64url(signature). If the signature is empty, you still get a trailing dot.

Header:  {"typ":"JWT","alg":"zzz"}
Payload: {"sub":"admin","name":"Mallory","iat":1700000000,"role":"admin"}

An attacker base64url-encodes header and payload, and sends:

<base64url(header)>.<base64url(payload)>.

No secret key required. No cryptography required. Just string comparison with an empty signature.

Impact
  • Complete authentication bypass
  • User impersonation (forge any identity / sub)
  • Privilege escalation (forge any claims/roles)
  • Trivial exploitation (no key material needed)
The fix

The maintainer fixed the issue in commit e1e0ee9 by making the algorithm error state part of the verification decision (and by resetting the error before verification).

@@ -218,6 +218,9 @@ METHOD Verify( cJWT ) CLASS JWT
   LOCAL aJWT, aHeader, aPayload
   LOCAL cSignature, cNewSignature

+  // Reset error
+  ::cError := ''
+

@@ -256,7 +259,7 @@
   cNewSignature := ::GetSignature( aJWT[1], aJWT[2], ::cSecret, aHeader[ 'alg' ] )
-  IF ( cSignature != cNewSignature )
+  IF ( cSignature != cNewSignature .OR. !EMPTY(::cError) )
     ::cError := "Invalid signature"
     RETU .F.
   ENDIF

In plain terms: unknown algorithm now leads to a failed verification, even if both signatures would have been empty strings.

Regression tests

The same patch also adds/extends tests to cover multiple algorithms and to prevent regressions around invalid algorithms and signatures. This ensures the bypass cannot be silently reintroduced.

Related: another JWT issue found using the same approach

This wasn’t the only JWT issue I found while doing automated triage + manual validation. Using the same “jwt-library-review” workflow, I also found and reported a JWT signature verification bypass in a Swift library: GHSA-88q6-jcjg-hvmw.

Takeaway: LLMs let you review code in languages you don't know

I didn't need to learn Harbour to find this bug. The LLM translated an unfamiliar language into something I could reason about, and from there I had options: triage the findings myself, or use the LLM to help with that too.

If you want to get better at finding issues like this, check out our live training on manual security code review. Understanding security patterns (like JWT verification flows) helps you craft the right prompts, judge LLM output, and triage findings, regardless of the language.

References
Photo of Louis Nyffenegger
Written by Louis Nyffenegger
Founder and CEO @PentesterLab