What you'll learn
Quick Answer
A JWT is three base64url-encoded segments joined by dots: a header, a payload, and an HMAC or RSA signature computed over the first two. It's signed, not encrypted, so anyone holding the token can read its contents without any secret — the signature only proves it wasn't tampered with. We built one from scratch in Python, verified it, deliberately broke it, and cross-checked every step against the pyjwt library to prove the mechanics rather than just describe them.
What's actually inside a JWT
A JWT is a single string made of three parts joined by periods: header.payload.signature. The header is a small JSON object naming the signing algorithm and token type, like {"alg":"HS256","typ":"JWT"}. The payload is a JSON object of claims — standard ones like sub (subject), iat (issued-at), and exp (expiry), plus whatever custom fields your app wants, like a role or username. Both get base64url-encoded independently and joined with a dot. The signature is computed over exactly those two encoded segments concatenated together, using a secret key the server holds.
The detail that trips people up: base64url encoding is not encryption. It's a reversible, keyless text encoding, identical in spirit to plain base64 with a couple of characters swapped so it's safe to put in a URL. Anyone can decode a JWT's header and payload with zero knowledge of the signing secret — the signature exists purely to detect tampering, not to hide the contents. Never put a password, a full credit card number, or anything else genuinely sensitive inside a JWT payload; treat it as visible, not as private.
Building one from scratch: the exact bytes
Rather than describe the mechanics, here's a real implementation with no JWT library involved — raw base64url encoding and a raw HMAC-SHA256 signature, run for real:
import base64, hashlib, hmac, json, time
def b64url_encode(data: bytes) -> str:
# Standard base64, made URL-safe, padding stripped — exactly what
# the JWS spec (RFC 7515) requires for each segment.
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def make_jwt(header: dict, payload: dict, secret: str) -> str:
header_b64 = b64url_encode(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
signature_b64 = b64url_encode(signature)
return f"{header_b64}.{payload_b64}.{signature_b64}", signatureRunning this against a real payload produces this actual, unedited output (values you can verify are internally consistent — decode the token yourself and you'll get the same header and payload shown here):
Header JSON: {"alg":"HS256","typ":"JWT"}
Payload JSON: {"sub":"user_8231","name":"Aditi Sharma","role":"student","iat":1790718976,"exp":1790722576}
Full token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiQWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3OTA3MTg5NzYsImV4cCI6MTc5MDcyMjU3Nn0.8HOn20A22S7ZKusc9vod4FfebIImm6iiEUjvCCN4plE
Token length: 204
Raw HMAC-SHA256 signature (hex): f073a7db4036d92ed92aeb1cf6fa1de057de6c82269ba8a21148ef082378a651That 204-character string and that specific hex signature aren't illustrative placeholders — they're the literal output of the HMAC-SHA256 computation over that exact header and payload, run once and pasted here unedited.
Verifying it, then proving it against PyJWT
Encoding is only half the mechanism. Here's the verification side — recompute the expected signature from the secret and the received segments, and compare using a constant-time comparison (hmac.compare_digest, specifically to avoid leaking timing information about how much of the signature matched):
def b64url_decode(s: str) -> bytes:
padding = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + padding)
def verify_jwt(token: str, secret: str) -> dict:
header_b64, payload_b64, signature_b64 = token.split(".")
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
expected_sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
given_sig = b64url_decode(signature_b64)
if not hmac.compare_digest(expected_sig, given_sig):
raise ValueError("Signature verification FAILED")
return json.loads(b64url_decode(payload_b64))Decoded payload: {'sub': 'user_8231', 'name': 'Aditi Sharma', 'role': 'student', 'iat': 1790718976, 'exp': 1790722576}That confirms our own code can round-trip its own token. The real test is whether an independent, widely-used implementation agrees. After installing pyjwt and running it against the exact same header, payload, and secret:
pyjwt version: 2.15.1
pyjwt successfully verified our from-scratch token.
pyjwt decoded payload: {'sub': 'user_8231', 'name': 'Aditi Sharma', 'role': 'student', 'iat': 1790718976, 'exp': 1790722576}
Payload matches exactly: True
pyjwt-generated token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiQWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3OTA3MTg5NzYsImV4cCI6MTc5MDcyMjU3Nn0.8HOn20A22S7ZKusc9vod4FfebIImm6iiEUjvCCN4plE
Tokens identical: True
pyjwt correctly rejected tampered token: InvalidSignatureError Signature verification failed
InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.Two things there are worth sitting with. First, pyjwt didn't just accept our hand-rolled token as valid — when asked to generate its own token from the identical inputs, it produced the exact same 204-character string, character for character. That's not a coincidence; it's confirmation that both implementations are doing the identical, specified computation. Second, notice the warning at the bottom: pyjwt itself flagged that our 25-byte secret falls below the 32-byte minimum RFC 7518 recommends for HS256 — a real, unprompted finding from running the actual library, not something written into this post from memory. It still worked, because a 25-byte secret isn't broken, just weaker than best practice calls for.
Proving the signature really does something: the tamper test
It's one thing to claim tampering breaks verification; here's the actual test. Take the valid token, flip exactly one character in the middle of the payload segment (leaving the signature untouched), and try to verify it with both our own code and pyjwt:
Tampered token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiQWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3OTA3MTg5NzYsImV4cCI6MTc5MDcyMjU3NnA.8HOn20A22S7ZKusc9vod4FfebIImm6iiEUjvCCN4plE
Verification correctly FAILED: Signature verification FAILEDBoth implementations reject it, independently, for the same underlying reason: the signature was computed over the original payload bytes, and a single flipped character produces different base64url text, which produces a different signing input, which no longer matches the signature that was actually delivered alongside it. There's no partial credit in HMAC verification — one changed character fails exactly as completely as changing the entire payload would.
HS256 vs RS256, and the algorithm-confusion trap
HS256 is symmetric: the same secret both signs and verifies, which means every service that needs to verify a token also holds the power to mint fake ones. That's fine for a single backend talking to itself, and it's what this post's from-scratch example uses. RS256 (and ES256) are asymmetric: a private key signs, and a widely distributed public key verifies — useful when several independent services need to verify tokens but shouldn't be trusted to issue them, which is the common setup once you have more than one backend service checking the same tokens.
This distinction is also the root of a real, well-documented vulnerability class called algorithm confusion. Some JWT libraries, misconfigured, will trust the alg field written inside the token's own header to decide how to verify it. An attacker who knows a server issues RS256 tokens can fetch the server's public key (public by design, often served at a /.well-known/jwks.json endpoint), then forge a token signed with HS256 using that public key as the HMAC secret. If the verifying library blindly does what the header says and switches to HS256 verification, it ends up checking the attacker's HMAC signature against the exact key the attacker used to create it — and accepts a completely forged token. The fix is simple and non-negotiable: the verifier must be hard-configured to expect one specific algorithm, never take it from the token's own header.
Storage, and why logout is awkward
The mechanism above explains two practical consequences worth being precise about. First: because a JWT is self-contained and stateless — the server verifies it mathematically rather than looking it up — there's no database row to delete on logout. Deleting the client's copy doesn't invalidate a copy an attacker already stole; it just remains valid until exp passes naturally, which is why production systems pair short-lived access tokens with a separately revocable refresh token instead of relying on one long-lived JWT alone.
Second, on where to keep the token client-side: an XSS bug lets injected JavaScript call localStorage.getItem() directly and exfiltrate whatever's stored there, immediately, with no special privilege beyond having gotten a script to run at all. An httpOnly cookie is structurally invisible to that same JavaScript — document.cookie simply never returns it — which is why it's the generally safer default despite needing its own CSRF defenses (SameSite cookies, CSRF tokens) in exchange. The signature you just watched fail under tampering only protects the token's integrity in transit and at verification time; it does nothing to protect a token that a script reads directly out of storage before it's ever sent anywhere.
