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.
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.
HarbourJwt incorrectly accepts forged tokens when the JWT header contains an algorithm value not in
HS256, HS384, or HS512.
"alg":"none" issue.none, None, NONE, foo, zzz, an empty string, etc.
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.
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.
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 "")"")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.
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.
sub)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.
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.
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.
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.