JWT : Un module de validation en Python

, par B. Degoy

Un module utilitaire pour :

– décoder un JWT,
– vérifier sa signature (HMAC ou RSA),
– protèger contre les attaques par timing,
– lèver des exceptions explicites,
– valider les claims pour garantir que le token est encore valable et destiné au service.

C’est une implémentation qui évite les boîtes noires, donc parfaitement maîtrisée.

Ce module Python fournit une implémentation bas niveau. Il ne dépend pas de bibliothèques haut niveau comme PyJWT : on maîtrise entièrement le processus.

Comment cela fonctionne (étape par étape)

– 1) Découpage du JWT
Le token doit avoir la forme :

header.payload.signature

Le code vérifie :
– qu’il y a bien 3 segments,
– qu’ils sont valides en Base64URL,
– qu’ils contiennent du JSON correct.

En cas de problème → JWTFormatError.

– 2) Décodage Base64URL
Chaque segment est décodé en bytes, avec ajout automatique du padding = si nécessaire.

En cas d’erreur → JWTFormatError.

– 3) Analyse du header
Le header doit contenir un champ :

"alg" : "HS256" | "RS256" | ...

Si l’algorithme est absent ou non autorisé → JWTAlgorithmError.

– 4) Vérification de la signature
Selon l’algorithme :

– HMAC (HS256 / HS384 / HS512)
Recalcul du HMAC avec la clé secrète
Comparaison en temps constant (Time Constant Comparison) : compare_digest
→ protège contre les attaques par timing

Si la signature ne correspond pas → JWTSignatureError.

– RSA (RS256 / RS384 / RS512)
Chargement de la clé publique PEM
Vérification via cryptography + PKCS#1 v1.5

Si la signature est invalide → JWTSignatureError.

– 5) Retour du payload
Si tout est correct, la fonction renvoie le **payload décodé** (dict Python).

Voici le code :

# jwt_validation.py
# coding: utf8

"""    
Décodage et validation d'un Jeton d'Identité JSON Web Token (JWT).

Auteur : 
    B.Degoy bertrand@degoy.com    
copyright (c) 2026 B.Degoy
licence : MIT
"""

import json
import base64
import hmac
import hashlib
from typing import Any, Dict


class JWTDecodeError(Exception):
    """Base class for all JWT decoding errors."""
    pass


class JWTFormatError(JWTDecodeError):
    """Raised when the JWT structure is invalid."""
    pass


class JWTAlgorithmError(JWTDecodeError):
    """Raised when the algorithm is missing or not allowed."""
    pass


class JWTSignatureError(JWTDecodeError):
    """Raised when the signature is invalid."""
    pass


class JWTUtils:
    """
    A static utility class providing low-level JWT decoding and signature
    verification, equivalent to the provided PHP implementation but using
    Python exceptions instead of returning False.

    This class does NOT validate claims (exp, nbf, iss, etc.).
    """

    # ----------------------------------------------------------------------
    # Base64URL decoding
    # ----------------------------------------------------------------------
    @staticmethod
    def urlsafe_b64decode(data: str) -> bytes:
        """
        Decode a Base64URL string into raw bytes.
        Adds required padding if missing.
        """
        padding = "=" * (-len(data) % 4)
        try:
            return base64.urlsafe_b64decode(data + padding)
        except Exception as e:
            raise JWTFormatError(f"Invalid Base64URL segment: {data}") from e

    # ----------------------------------------------------------------------
    # HMAC signing
    # ----------------------------------------------------------------------
    @staticmethod
    def sign(message: str, key: bytes, algo: str) -> bytes:
        """
        Compute an HMAC signature for the given message using HS256/384/512.
        """
        if algo == "HS256":
            return hmac.new(key, message.encode(), hashlib.sha256).digest()
        if algo == "HS384":
            return hmac.new(key, message.encode(), hashlib.sha384).digest()
        if algo == "HS512":
            return hmac.new(key, message.encode(), hashlib.sha512).digest()

        raise JWTAlgorithmError(f"Unsupported HMAC algorithm: {algo}")

    # ----------------------------------------------------------------------
    # Constant-time comparison
    # ----------------------------------------------------------------------
    @staticmethod
    def hash_equals(a: bytes, b: bytes) -> bool:
        """Constant-time comparison to avoid timing attacks."""
        return hmac.compare_digest(a, b)

    # ----------------------------------------------------------------------
    # RSA signature verification
    # ----------------------------------------------------------------------
    @staticmethod
    def verify_rsa(signature: bytes, message: str, key_pem: str, algo: str) -> bool:
        """
        Verify an RSA signature using a PEM-encoded public key.
        Supported algorithms: RS256, RS384, RS512.
        """
        from cryptography.hazmat.primitives import serialization, hashes
        from cryptography.hazmat.primitives.asymmetric import padding

        public_key = serialization.load_pem_public_key(key_pem.encode())

        hash_algo = {
            "RS256": hashes.SHA256(),
            "RS384": hashes.SHA384(),
            "RS512": hashes.SHA512(),
        }.get(algo)

        if hash_algo is None:
            raise JWTAlgorithmError(f"Unsupported RSA algorithm: {algo}")

        try:
            public_key.verify(
                signature,
                message.encode(),
                padding.PKCS1v15(),
                hash_algo
            )
            return True
        except Exception:
            return False

    # ----------------------------------------------------------------------
    # Signature verification dispatcher
    # ----------------------------------------------------------------------
    @staticmethod
    def verify_signature(signature: bytes, input_data: str, key: Any, algo: str) -> None:
        """
        Verify the signature for the given algorithm.
        Raises JWTSignatureError on failure.
        """
        if algo in ("HS256", "HS384", "HS512"):
            key_bytes = key if isinstance(key, bytes) else key.encode()
            expected = JWTUtils.sign(input_data, key_bytes, algo)

            if not JWTUtils.hash_equals(expected, signature):
                raise JWTSignatureError("Invalid HMAC signature")
            return

        if algo in ("RS256", "RS384", "RS512"):
            if not JWTUtils.verify_rsa(signature, input_data, key, algo):
                raise JWTSignatureError("Invalid RSA signature")
            return

        raise JWTAlgorithmError(f"Unsupported or invalid signing algorithm: {algo}")

    # ----------------------------------------------------------------------
    # Main decode function
    # ----------------------------------------------------------------------
    @staticmethod
    def decode(jwt: str, key: Any = None, allowed_algorithms: Any = True) -> Dict:
        """
        Decode a JWT token, validate its structure, optionally verify its
        signature, and return the payload as a dictionary.

        Raises:
            JWTFormatError: Invalid structure or Base64URL segments.
            JWTAlgorithmError: Missing or disallowed algorithm.
            JWTSignatureError: Signature verification failed.
        """
        # Basic structure check
        if "." not in jwt:
            raise JWTFormatError("JWT must contain at least one dot")

        parts = jwt.split(".")
        if len(parts) != 3:
            raise JWTFormatError("JWT must contain exactly 3 segments")

        head_b64, payload_b64, sig_b64 = parts

        # Decode JSON header and payload
        try:
            header = json.loads(JWTUtils.urlsafe_b64decode(head_b64))
        except Exception as e:
            raise JWTFormatError("Invalid JWT header") from e

        try:
            payload = json.loads(JWTUtils.urlsafe_b64decode(payload_b64))
        except Exception as e:
            raise JWTFormatError("Invalid JWT payload") from e

        signature = JWTUtils.urlsafe_b64decode(sig_b64)

        # Algorithm checks
        if allowed_algorithms:
            if "alg" not in header:
                raise JWTAlgorithmError("Missing 'alg' in JWT header")

            algo = header["alg"]

            if isinstance(allowed_algorithms, list) and algo not in allowed_algorithms:
                raise JWTAlgorithmError(f"Algorithm '{algo}' not allowed")

            signing_input = f"{head_b64}.{payload_b64}"

            JWTUtils.verify_signature(signature, signing_input, key, algo)

        return payload

Validation des déclarations (claims)

L’utilitaire ci-dessus décode le JWT et vérifie la signature — mais **il ne valide pas les claims**, c’est‑à‑dire les champs qui définissent *quand* et *dans quelles conditions* le token est valable.

Quels claims doivent être validés ?

Les principaux champs standardisés dans un JWT sont :

Claim Signification Validation
exp Expiration time Le token doit être encore valide
nbf Not Before Le token ne doit pas être utilisé trop tôt
iat Issued At Optionnel : vérifier que la date n’est pas aberrante
iss Issuer Vérifier que le token vient du bon émetteur
aud Audience Vérifier que le token est destiné à ton service
sub Subject Optionnel : vérifier l’identité attendue

Fonction validate_claims()

Voici comment ajouter cette validation :
On ajoute une fonction **validate_claims(payload, options)** qui :
– lit les champs du payload,
– compare avec l’heure actuelle,
– lève des exceptions explicites si quelque chose ne va pas.

from datetime import datetime, timezone


class JWTClaimsError(Exception):
    """Raised when JWT claims validation fails."""
    pass


class JWTUtils:
    # ... (tout le reste de ta classe ici)

    @staticmethod
    def validate_claims(
        payload: dict,
        issuer: str = None,
        audience: str = None,
        leeway: int = 0
    ) -> None:
        """
        Validate standard JWT claims (exp, nbf, iss, aud).

        Parameters:
            payload (dict): The decoded JWT payload.
            issuer (str|None): Expected 'iss' value.
            audience (str|None): Expected 'aud' value.
            leeway (int): Allowed clock skew in seconds.

        Raises:
            JWTClaimsError: If any claim is invalid.
        """

        now = datetime.now(timezone.utc).timestamp()

        # --- exp: expiration time ---
        if "exp" in payload:
            if now > payload["exp"] + leeway:
                raise JWTClaimsError("Token has expired")

        # --- nbf: not before ---
        if "nbf" in payload:
            if now < payload["nbf"] - leeway:
                raise JWTClaimsError("Token is not yet valid (nbf)")

        # --- iat: issued at ---
        if "iat" in payload:
            if payload["iat"] > now + leeway:
                raise JWTClaimsError("Token issued in the future (iat)")

        # --- iss: issuer ---
        if issuer is not None:
            if payload.get("iss") != issuer:
                raise JWTClaimsError(f"Invalid issuer: {payload.get('iss')}")

        # --- aud: audience ---
        if audience is not None:
            aud = payload.get("aud")
            if isinstance(aud, list):
                if audience not in aud:
                    raise JWTClaimsError(f"Audience '{audience}' not allowed")
            else:
                if aud != audience:
                    raise JWTClaimsError(f"Invalid audience: {aud}")
</python>

{{intégration dans <span class="base64-bc809d0d" title="PGNvZGUgY2xhc3M9J3NwaXBfY29kZSBzcGlwX2NvZGVfaW5saW5lJyBkaXI9J2x0cic+ZGVjb2RlKCk8L2NvZGU+"></span>}}

On ajoute un paramètre optionnel :

<code class="python">
@staticmethod
def decode(jwt, key=None, allowed_algorithms=True, validate=False, issuer=None, audience=None):
    payload = ...  # comme avant

    if validate:
        JWTUtils.validate_claims(payload, issuer=issuer, audience=audience)

    return payload
</python>

Et on l’appelle comme ceci :

<code class="python">
payload = JWTUtils.decode(
    token,
    key=public_key,
    allowed_algorithms=["RS256"],
    validate=True,
    issuer="https://auth.example.com",
    audience="my-api"
)