11 KiB
Implementation Guide
This document walks through the real code, file by file, in the order the data flows. Every snippet is copied from the project, with the file and line noted so you can open it alongside.
File Structure Walkthrough
src/jwt_auditor/
├── decoder.py # parse a token string into DecodedToken
├── signatures.py # HMAC sign, verify, crack, confusion test
├── checks.py # the checks and the audit() runner
├── models.py # Severity, Finding, AuditReport
├── output.py # Rich and JSON rendering
├── wordlist.py # built in secrets and sensitive claim names
└── main.py # the Typer CLI
Building the Decoder
Step 1: base64url with the padding put back
JWT uses base64url and strips the = padding to keep tokens short. The standard
decoder wants that padding, so we add it back before decoding.
src/jwt_auditor/decoder.py:33
def _b64url_decode(segment: str) -> bytes:
padding = "=" * (-len(segment) % 4)
try:
return base64.urlsafe_b64decode(segment + padding)
except (binascii.Error, ValueError) as exc:
raise InvalidTokenError(f"segment is not valid base64url: {exc}") from None
Why this code works:
-len(segment) % 4computes how many pad characters are missing. For a length that is already a multiple of 4 it is 0, otherwise 1, 2, or 3.- We convert every decoding error into
InvalidTokenError, so callers deal with one exception type instead ofbinasciiinternals.
Common mistake here:
# Wrong: no padding, base64 rejects most real segments
base64.urlsafe_b64decode(segment)
# Why this fails: JWT segments are almost never a multiple of 4 in length,
# so the decoder raises "Invalid base64-encoded string".
Step 2: parse into a DecodedToken
decode splits the token, decodes both JSON parts, and keeps the signing input.
src/jwt_auditor/decoder.py:90
def decode(token: str) -> DecodedToken:
token = token.strip()
if not token:
raise InvalidTokenError("token is empty")
segments = token.split(".")
if len(segments) != 3:
raise InvalidTokenError(
f"a JWT has 3 dot separated segments, this has {len(segments)}"
)
header_b64, payload_b64, signature_b64 = segments
header = _decode_json_segment(header_b64, "header")
payload = _decode_json_segment(payload_b64, "payload")
signature = _b64url_decode(signature_b64) if signature_b64 else b""
return DecodedToken(
raw=token,
header=header,
payload=payload,
signature=signature,
signing_input=f"{header_b64}.{payload_b64}".encode("ascii"),
header_b64=header_b64,
payload_b64=payload_b64,
signature_b64=signature_b64,
)
What is happening:
- An empty signature segment (the
alg: nonecase) is allowed. We storeb""rather than raising, because a check further down needs to see it. signing_inputis computed once, from the original base64 text, not by re-encoding the parsed JSON. That matters: re-encoding could reorder keys or change spacing and produce different bytes than what was signed.
Why we do it this way: the decoder is deliberately trusting. It reports what
the token says. Deciding whether the token is dangerous is the job of checks.py,
not the parser.
Building the Signature Layer
Verifying in constant time
src/jwt_auditor/signatures.py:58
def verify_hmac(token: DecodedToken, secret: bytes, alg: str | None = None) -> bool:
chosen = alg or token.algorithm
if chosen not in _HASH_BY_ALG:
return False
if not token.signature:
return False
expected = hmac_sign(token.signing_input, secret, chosen)
return hmac.compare_digest(expected, token.signature)
Key parts explained:
hmac.compare_digest (src/jwt_auditor/signatures.py:78) is the important line.
A normal == on bytes short circuits at the first differing byte, so a wrong
guess that shares a longer prefix takes measurably longer. Over many requests an
attacker can use that timing to recover the signature one byte at a time.
compare_digest always takes the same time for equal length inputs.
The alg parameter defaults to the token's declared algorithm but can be forced.
That is what lets the confusion test say "verify this as HS256 even though it
claims RS256".
Cracking a secret
src/jwt_auditor/signatures.py:81
def crack_hmac_secret(token, candidates):
if token.algorithm not in _HASH_BY_ALG:
return None
for candidate in candidates:
if verify_hmac(token, candidate.encode("utf-8")):
return candidate
return None
This is the whole attack. For a non HMAC token there is no shared secret, so it returns immediately instead of pointlessly hashing the wordlist. On the first match it returns the secret.
The key confusion test
src/jwt_auditor/signatures.py:100
def key_confusion_secret(token, public_key_pem):
variants = {
"public key PEM as stored": public_key_pem,
"public key PEM without trailing newline": public_key_pem.rstrip(b"\n"),
"public key PEM with trailing newline": public_key_pem.rstrip(b"\n") + b"\n",
}
for alg in _HASH_BY_ALG:
for label, material in variants.items():
if verify_hmac(token, material, alg=alg):
return f"{label} (verified as {alg})"
return None
Why the variants: HMAC is over exact bytes. Whether the server stored the PEM with a trailing newline changes every byte of the output. Servers differ, so we try the common forms rather than guess one.
Building the Checks
Each check is small and returns findings. Here is the alg: none one in full.
src/jwt_auditor/checks.py:76
def check_alg_none(token: DecodedToken) -> list[Finding]:
if token.algorithm.lower() != "none":
return []
return [
Finding(
check_id="alg-none",
title="Algorithm is 'none' (unsigned token)",
severity=Severity.CRITICAL,
detail=(
"The header declares alg 'none', meaning the token is not "
"signed at all. A server that honors this accepts any payload "
"an attacker types, including admin claims."
),
evidence=f"header.alg = {token.header.get('alg')!r}",
recommendation=(
"Reject 'none' outright. Verify against an explicit allowlist "
"of algorithms and never let the token pick its own."
),
)
]
Note token.algorithm.lower(). The attack has been carried out with none,
None, and NONE to slip past a case sensitive string compare, so we normalize.
A subtle bug this design avoids: bool is an int
src/jwt_auditor/checks.py:66
def _as_timestamp(payload, claim):
value = payload.get(claim)
if isinstance(value, bool): # bool is an int subclass, reject it explicitly
return None
if isinstance(value, int | float):
return float(value)
return None
In Python, True is an instance of int and equals 1. Without the explicit
bool check, a token with "exp": true would be read as expiring at Unix time
1, which is nonsense. test_bool_claim_is_not_treated_as_timestamp in
tests/test_checks.py locks this behavior in.
The audit runner
src/jwt_auditor/checks.py:391
def audit(token, *, now=None, wordlist=None, public_key_pem=None,
max_lifetime_hours=_DEFAULT_MAX_LIFETIME_HOURS):
current = time.time() if now is None else now
secrets = COMMON_SECRETS if wordlist is None else wordlist
findings: list[Finding] = []
findings += check_alg_none(token)
findings += check_unknown_algorithm(token)
findings += check_unsigned(token)
findings += check_weak_hmac_secret(token, secrets)
findings += check_key_confusion(token, public_key_pem)
findings += check_expiration(token, current, max_lifetime_hours)
findings += check_time_sanity(token, current)
findings += check_missing_claims(token)
findings += check_sensitive_data(token)
return AuditReport(token=token, findings=findings)
now is injectable. In production it is time.time(). In tests it is a fixed
value (FIXED_NOW in tests/conftest.py) so the expiration checks are
deterministic. That single design choice is why the time based tests are not
flaky.
The risk score
src/jwt_auditor/models.py
@property
def risk_score(self) -> float:
if not self.findings:
return 0.0
base = max(f.severity.weight for f in self.findings)
extra = 0.4 * (len(self.findings) - 1)
return round(min(10.0, base + extra), 1)
The worst finding sets the floor. Extra findings nudge it up, because five medium
issues are worse than one. It is capped at 10. A clean token is exactly 0.0.
tests/test_models.py covers the empty, single, and capped cases.
Error Handling in the CLI
src/jwt_auditor/main.py
def _decode_or_exit(raw: str) -> DecodedToken:
try:
return decode(raw)
except InvalidTokenError as exc:
err_console.print(f"[red]Not a valid JWT:[/red] {exc}")
raise typer.Exit(code=2) from None
What NOT to do:
# Bad: swallow everything
try:
return decode(raw)
except Exception:
return None # now every caller has to wonder what None means
We catch the one exception the decoder raises and turn it into a clean exit code 2 with a message. Everything else is a real bug and should crash loudly.
Testing Strategy
Unit test for a single check
tests/test_checks.py
def test_weak_secret_recovered():
token = decode(build_hs_token({"sub": "x"}, secret="secret"))
findings = checks.check_weak_hmac_secret(token, ["secret", "other"])
assert findings[0].severity is Severity.CRITICAL
assert "secret" in findings[0].evidence
The fixture build_hs_token in tests/conftest.py signs a token with the
project's own hmac_sign, so the test exercises the real signing and cracking
path with no external library.
End to end test through the CLI
tests/test_cli.py
def test_audit_none_token_fails_and_reports_critical():
token = build_none_token({"sub": "x"})
result = runner.invoke(app, ["audit", token])
assert result.exit_code == 1
assert "CRITICAL" in result.stdout
Typer's CliRunner runs the command in process and captures output and the exit
code, so this checks the real argument parsing and the fail level logic together.
Running the tests
uv run pytest tests/ -v
All 59 pass. If one fails with an import error, you probably skipped uv sync.
Dependencies
- typer - the CLI framework. Gives us subcommands, help text, and exit codes with almost no boilerplate.
- rich - tables and panels for readable output, JSON pretty printing.
That is the entire runtime dependency list. Signatures, base64, and JSON all come from the standard library, which keeps the security relevant code auditable in one sitting.
Next Steps
Read 04-CHALLENGES.md to extend the tool. Good first steps:
a new check, or real RSA verification with the cryptography library.