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}", signature

Running 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): f073a7db4036d92ed92aeb1cf6fa1de057de6c82269ba8a21148ef082378a651

That 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 FAILED

Both 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.

Frequently Asked Questions

What's the practical difference between HS256 and RS256, and when should I use which? HS256 uses one shared secret for both signing and verifying, so anything that can verify a token can also forge one — fine for a single service. RS256 signs with a private key and verifies with a public one, so you can hand the public key to many services that need to verify tokens without any of them being able to mint new ones.
What is the algorithm-confusion attack in plain terms? It happens when a verifier trusts the alg field inside the token's own header instead of being hard-configured for one expected algorithm. An attacker can fetch a server's public RS256 key (public by design) and use it as an HMAC secret to forge an HS256-signed token, which a misconfigured verifier will accept.
Why did our from-scratch token and pyjwt's generated token come out byte-for-byte identical? Because JWT construction is fully specified (RFC 7515/7519): the same header, same payload, same secret, and same algorithm always produce the same base64url segments and the same HMAC-SHA256 signature. Any two correct implementations must converge on the same output for the same input.
Does the order of keys in the JSON payload change the resulting token? Yes, in principle — the signature is computed over the literal encoded bytes, so a different key order produces different JSON text, different base64url output, and a different signature, even though the decoded data is logically the same. Correct implementations are consistent about key order internally, which is why this doesn't cause interoperability problems in practice.
What happens if the HMAC secret is too short? It still functions — pyjwt only issues a warning, not an error, for a 25-byte HS256 key in our test — but a short secret is easier to brute-force offline against a captured token. RFC 7518 recommends at least 32 bytes (256 bits) for HS256 specifically because the security of the signature depends on the secret being infeasible to guess.
If I can decode a JWT's payload without the secret, can I also forge a valid one without it? No. Decoding only reverses the base64url text encoding, which requires no secret. Producing a signature that will pass verification requires knowing the actual signing key (the HMAC secret, or the RSA private key), which is what actually prevents forgery.