Skip to content

Use constant-time comparison for the JWE auth tag and the OIDC at_hash claim#411

Open
Nexory wants to merge 1 commit into
mpdavis:masterfrom
Nexory:harden/constant-time-compare
Open

Use constant-time comparison for the JWE auth tag and the OIDC at_hash claim#411
Nexory wants to merge 1 commit into
mpdavis:masterfrom
Nexory:harden/constant-time-compare

Conversation

@Nexory

@Nexory Nexory commented Jun 18, 2026

Copy link
Copy Markdown

Use constant-time comparison for the JWE auth tag and the OIDC at_hash claim

Two authentication-gating comparisons in the library use plain !=, which is
timing-variable and leaks how many leading bytes match (CWE-208):

  • jose/jwe.py _decrypt_and_auth: if auth_tag != auth_tag_check: - for the
    HMAC enc modes (A128CBC-HS256 / A192CBC-HS384 / A256CBC-HS512),
    auth_tag_check is the freshly computed HMAC and auth_tag is the
    attacker-supplied tag from the JWE; this != is the authentication gate, so a
    timing oracle helps an attacker forge a valid tag.
  • jose/jwt.py _validate_at_hash: if claims["at_hash"] != expected_hash: -
    claims["at_hash"] is attacker-influenced (the JWT payload) and
    expected_hash is the server-computed value that binds the ID token to the
    access token (OIDC).

Both are switched to hmac.compare_digest, which is the timing-safe primitive
already used elsewhere in this package (jose/backends/native.py
HMACKey.verify). For at_hash, a non-string claim is treated as invalid (the
previous != semantics) so compare_digest is never called with a non-string.

Behavior is unchanged for valid and invalid inputs; only the comparison is now
constant-time. tests/test_jwe.py and tests/test_jwt.py pass (180 passed, 6
skipped); flake8 is clean.

Refs the open issue #398, which lists both spots.

…h claim

Two authentication-gating comparisons used a plain `!=`, which is
timing-variable and leaks how many leading bytes match (CWE-208):

- jose/jwe.py `_decrypt_and_auth`: for the HMAC enc modes
  (A128CBC-HS256 / A192CBC-HS384 / A256CBC-HS512) `auth_tag_check` is the
  freshly computed HMAC and `auth_tag` is the attacker-supplied tag from
  the JWE, so this is the authentication gate.
- jose/jwt.py `_validate_at_hash`: `claims["at_hash"]` is attacker-influenced
  (the JWT payload) and `expected_hash` binds the ID token to the access
  token (OIDC).

Both now use hmac.compare_digest, the timing-safe primitive already used in
jose/backends/native.py (HMACKey.verify). For at_hash a non-string claim is
treated as invalid (preserving the previous semantics) so compare_digest is
never called with a non-string.

Refs mpdavis#398.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant