JWTs — Creating and Verifying Without a Library
A JWT is three base64url-encoded chunks separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIEpvaG5zb24iLCJyb2xlIjoiYWRtaW4ifQ.
_iCoFoqU95jHmfP5RWhPCr2rvMSGJhkolIvlNOAMKKE
Everyone who uses JWTs has seen that string. Most people treat it as magic — paste a library call, get a token back, pass it in an Authorization header. But the token itself isn’t encrypted. Anyone who intercepts it can decode the first two parts and read every claim inside. The only thing protecting the token is the signature in the third part.
This post walks through creating and verifying a JWT from scratch with nothing but Python’s standard library (base64, hmac, hashlib). No PyJWT. No dependencies.
The three parts, decoded
Let’s create a token with some claims and then decode each part by hand to see what’s actually inside.
{"alg": "HS256", "typ": "JWT"}
The header is plain JSON — just enough metadata about how the token was signed. It’s base64url-encoded so it can ride safely in a URL or HTTP header.
{
"sub": "1234567890",
"name": "Alice Johnson",
"role": "admin",
"iat": 1789052563,
"exp": 1789056163
}
The payload carries whatever claims the issuer wants to assert. sub (subject) is the standard claim for a user identifier; iat and exp are the issued-at and expiration timestamps. These can be any JSON — there’s no schema enforcement.
15368ff98f16ff2389943cb26391081a429a98b56fd46e3f65de9738bbded584
The signature is 32 bytes of HMAC-SHA256 — exactly the output length of SHA-256. It’s computed over the concatenation header.b64url + "." + payload.b64url using a shared secret key. Anyone who changes even one character in the header or payload will produce a different signature, and the server can detect that by recomputing and comparing.
The code
Here’s the entire implementation:
import base64
import hashlib
import hmac
import json
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def base64url_decode(s: str) -> bytes:
s += "=" * (4 - len(s) % 4)
return base64.urlsafe_b64decode(s)
def create_jwt(payload: dict, secret_key: str, algorithm: str = "HS256") -> str:
header = {"alg": algorithm, "typ": "JWT"}
header_encoded = base64url_encode(json.dumps(header, separators=(',', ':')).encode())
payload_encoded = base64url_encode(json.dumps(payload, separators=(',', ':')).encode())
signing_input = f"{header_encoded}.{payload_encoded}"
signature = hmac.new(
secret_key.encode("utf-8"),
signing_input.encode("utf-8"),
hashlib.sha256,
).digest()
return f"{signing_input}.{base64url_encode(signature)}"
def verify_jwt(token: str, secret_key: str) -> dict | None:
parts = token.split(".")
if len(parts) != 3:
return None
header_b64, payload_b64, signature_b64 = parts
signing_input = f"{header_b64}.{payload_b64}"
expected_sig = hmac.new(
secret_key.encode("utf-8"),
signing_input.encode("utf-8"),
hashlib.sha256,
).digest()
if not hmac.compare_digest(signature_b64, base64url_encode(expected_sig)):
return None
payload = json.loads(base64url_decode(payload_b64))
if "exp" in payload and time.time() > payload["exp"]:
return None
return payload
The create_jwt function follows the five steps: encode header, encode payload, sign them together, encode the signature, join with dots. The verify_jwt function reverses the process — it recomputes the expected signature from the first two parts and compares it using hmac.compare_digest, a constant-time comparison that prevents timing attacks.
Running it
Step 1 creates the token (204 characters) and shows all three parts decoded. Notice that the header and payload are just readable JSON — the base64url encoding is encoding, not encryption. If you see someone claim that JWTs “encrypt” user data, that’s a misunderstanding; the signature only protects integrity, not confidentiality.
Step 2 verifies the token with the correct secret and successfully recovers every claim. The server doesn’t need to look anything up in a database — it trusts the signature as proof the issuer signed these claims.
Step 3 is where the whole mechanism becomes concrete. We change only the payload (swapping "role":"admin" for "role":"user") while keeping the original header and signature. The resulting token looks valid — it’s still three dot-separated base64url chunks — but verification fails immediately because the HMAC input changed and produces a different digest.
Step 4 confirms that even with the correct payload, using the wrong secret key also produces rejection. Both the tampered-payload case and the wrong-key case fail in exactly the same way: signature mismatch. The verifier never reaches the claims because integrity comes first.
Takeaway
A JWT is a signed container, not an encrypted one — you can read every claim inside it without a key, but you can’t change any of them without invalidating the 32-byte HMAC-SHA256 seal. The secret key never travels with the token; it stays on the server for verification only.