API Authentication with JWT

Reviewed & published by Brayan K

A JWT (JSON Web Token) is a signed, self-contained token that carries a user's identity, letting a stateless API verify who is calling without storing any server-side session.

Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll see a token's three parts, issue one on login, send it as a Bearer token, verify its signature on protected routes, and reject expired tokens.

What You'll Learn in This Lesson

1 Anatomy of a Token: header.payload.signature

A JWT is three base64url chunks joined by dots: header.payload.signature. The header names the algorithm; the payload holds your claims (user id, role, expiry); the signature is an HMAC of the first two parts using a secret key. Anyone can read the payload, but only someone holding the secret can produce a valid signature — so tampering is detectable.

The runnable example builds and verifies a token with Python's standard library (hmac, base64, json), so it runs anywhere. This is exactly what a JWT library does under the hood.

# jwt_demo.py — build and verify a JWT with the standard library (runnable)
import hmac, hashlib, base64, json, time

SECRET = b"super-secret-key"

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def encode_jwt(payload: dict) -> str:
    header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
    body = b64url(json.dumps(payload, separators=(",", ":")).encode())
    signing_input = f"{header}.{body}".encode()
    sig = b64url(hmac.new(SECRET, signing_input, hashlib.sha256).digest())
    return f"{header}.{body}.{sig}"   # the three-part token

def decode_jwt(token: str) -> dict:
    header, body, sig = token.split(".")
    expected = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(expected, sig):       # verify the signature
        raise ValueError("Invalid signature")
    payload = json.loads(b64url_decode(body))
    if "exp" in payload and payload["exp"] < time.time():
        raise ValueError("Token expired")
    return payload

token = encode_jwt({"sub": "ada", "role": "admin", "exp": time.time() + 3600})
print("three parts?", len(token.split(".")) == 3)
print("decoded:", decode_jwt(token))

# tamper with the signature -> verification fails
try:
    decode_jwt(token[:-4] + "XXXX")
except ValueError as e:
    print("rejected:", e)

Output: three parts? True, the decoded payload, then rejected: Invalid signature for the tampered token — the secret key is what makes forgery fail.

2 Issue on Login, Verify on a Protected Route

The flow: the client posts credentials to /login; on success the server returns a token. The client then sends that token in the Authorization: Bearer … header on every protected request, and the server verifies it before answering.

The runnable example wires that into Flask routes and exercises it with the test client — first rejected with no token, then accepted with a valid one.

# app.py — issue a token on login, verify it on /profile (runnable)
import hmac, hashlib, base64, json
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = b"super-secret-key"

def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def b64url_decode(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def make_token(payload):
    header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64url(json.dumps(payload).encode())
    sig = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
    return f"{header}.{body}.{sig}"

def verify_token(token):
    header, body, sig = token.split(".")
    expected = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(expected, sig):
        raise ValueError("bad signature")
    return json.loads(b64url_decode(body))

@app.route("/login", methods=["POST"])
def login():
    if request.form.get("password") == "secret":
        return jsonify(token=make_token({"sub": request.form.get("username")}))
    return jsonify(error="bad credentials"), 401

@app.route("/profile")
def profile():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify(error="missing token"), 401
    try:
        claims = verify_token(auth.split(" ", 1)[1])   # strip "Bearer "
    except ValueError:
        return jsonify(error="invalid token"), 401
    return jsonify(message=f"Hello {claims['sub']}, you are authenticated!")

with app.test_client() as c:
    print(c.get("/profile").status_code)                                   # 401 (no token)
    token = c.post("/login", data={"username": "ada", "password": "secret"}).get_json()["token"]
    r = c.get("/profile", headers={"Authorization": f"Bearer {token}"})
    print(r.get_json())                                                    # authenticated!

# ✅ Expected output:
# 401
# {'message': 'Hello ada, you are authenticated!'}

Output: 401 for the request with no token, then {'message': 'Hello ada, you are authenticated!'} once the Bearer token is attached.

3 The Production Tool: Flask-JWT-Extended

In production you do not hand-roll the signing. Flask-JWT-Extended creates and verifies tokens, reads the Bearer header for you, handles expiry, and provides a @jwt_required() decorator plus get_jwt_identity(). Here is the same flow in that library, shown read-only.

🎯 Your Turn: Fill in the Blanks

Complete the verifier. Replace each ___ to split the token, recompute the signature, and compare safely.

# 🎯 YOUR TURN: replace each ___ to verify a token
import hmac, hashlib, base64, json

SECRET = b"super-secret-key"

def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def b64url_decode(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def make_token(payload):
    header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64url(json.dumps(payload).encode())
    sig = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
    return f"{header}.{body}.{sig}"

def verify(token):
    header, body, sig = token.split("___")                # tokens are joined by dots
    expected = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(expected, ___):            # compare against the token's signature
        raise ValueError("bad signature")
    return json.loads(b64url_decode(___))                 # decode which part holds the claims?

tok = make_token({"sub": "ada"})
print("claims:", verify(tok))                             # {'sub': 'ada'}
try:
    verify(tok[:-2] + "ZZ")
except ValueError as e:
    print("tampered:", e)                                 # bad signature

Common Errors & Quick Reference

❌ 422 / "Missing Authorization Header"

The client did not send the token. It must include Authorization: Bearer <token> on every protected request.

❌ Invalid signature after deploy

The secret key changed (or differs between servers). All previously issued tokens fail verification. Keep one stable secret, loaded from an environment variable.

❌ Putting secrets in the payload

The payload is only base64-encoded, not encrypted — anyone can read it. Never put passwords or sensitive data in a JWT.

ConceptDetail
Token shapeheader.payload.signature
Send itAuthorization: Bearer <token>
Protect a route@jwt_required()
Read the userget_jwt_identity()

🏆 Mini Challenge: Reject an Expired Token

Add an expiry claim and enforce it on verification.

# 🏆 Mini Challenge: enforce token expiry
import hmac, hashlib, base64, json, time

SECRET = b"super-secret-key"
def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def b64url_decode(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def make_token(payload):
    h = b64url(json.dumps({"alg": "HS256"}).encode())
    b = b64url(json.dumps(payload).encode())
    sig = b64url(hmac.new(SECRET, f"{h}.{b}".encode(), hashlib.sha256).digest())
    return f"{h}.{b}.{sig}"

def verify(token):
    h, b, sig = token.split(".")
    expected = b64url(hmac.new(SECRET, f"{h}.{b}".encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(expected, sig):
        raise ValueError("bad signature")
    claims = json.loads(b64url_decode(b))
    if claims.get("exp", float("inf")) < time.time():
        raise ValueError("token expired")
    return claims

fresh = make_token({"sub": "ada", "exp": time.time() + 60})
print("fresh:", verify(fresh)["sub"])              # ada

stale = make_token({"sub": "ada", "exp": time.time() - 60})
try:
    verify(stale)
except ValueError as e:
    print("expired ->", e)                          # token expired

❓ Frequently Asked Questions

Lesson complete — your API is locked down with tokens!

You understand a JWT's three parts, how the HMAC signature stops tampering, how to issue a token on login, send it as a Bearer header, verify it on protected routes, and expire it with an exp claim.

Practice quiz

What are the three parts of a JWT, in order?

  • header.payload.signature
  • payload.header.signature
  • signature.header.payload
  • header.signature.payload

Answer: header.payload.signature. A JWT is header.payload.signature, three base64url chunks joined by dots.

Which JWT part holds the claims like user id and expiry?

  • The header
  • The payload
  • The signature
  • The secret

Answer: The payload. The payload carries the claims; the header names the algorithm.

What does the signature protect against?

  • Slow networks
  • Large payloads
  • Tampering with the token
  • Expired sessions

Answer: Tampering with the token. The HMAC signature over header+payload makes tampering detectable.

Who can produce a valid signature for a token?

  • Anyone who can read the payload
  • Any browser
  • The user only
  • Only someone holding the secret key

Answer: Only someone holding the secret key. Only the holder of the secret key can compute a valid HMAC signature.

How is a token sent on a protected request?

  • In the Authorization: Bearer header
  • As a URL query parameter named jwt
  • In a cookie called csrf
  • In the request body as form data

Answer: In the Authorization: Bearer header. Clients send 'Authorization: Bearer <token>' on protected requests.

Can anyone read a JWT's payload?

  • No, it is encrypted
  • Yes, it is only base64url-encoded, not encrypted
  • Only the server can
  • Only with the secret key

Answer: Yes, it is only base64url-encoded, not encrypted. The payload is base64url-encoded, so it is readable; the signature, not secrecy, provides integrity.

Which claim is used to expire a token?

  • iat
  • sub
  • exp
  • aud

Answer: exp. The exp claim holds the expiry time; tokens past it are rejected.

In Flask-JWT-Extended, which decorator protects a route?

  • @login_required
  • @app.protect
  • @token_only
  • @jwt_required()

Answer: @jwt_required(). @jwt_required() rejects missing, invalid, or expired tokens automatically.

What does create_access_token(identity=...) store the identity in?

  • The token's 'sub' claim
  • A server-side session
  • A cookie
  • The database

Answer: The token's 'sub' claim. create_access_token stores the identity in the token's sub claim.

Why are JWTs well suited to a stateless API?

  • They require a database lookup per request
  • They store sessions on the client's disk
  • The token itself carries verified identity, so no server-side session is needed
  • They encrypt every response

Answer: The token itself carries verified identity, so no server-side session is needed. A signed, self-contained token lets the server verify identity without storing a session.

Continue this course

Frequently asked questions

What is a JWT made of?

A JWT (JSON Web Token) has three dot-separated, base64url-encoded parts: a header (the algorithm, e.g. HS256), a payload (your claims, like the user id and an expiry), and a signature. The signature is an HMAC of header.payload using a secret key, which is what makes the token tamper-proof.

Why use a JWT instead of a session cookie?

Sessions store state on the server and rely on cookies. JWTs are stateless: the token itself carries the user's identity, signed so it cannot be forged. That suits APIs consumed by mobile apps or single-page front-ends, where the client stores the token and sends it on each request — no server-side session store required.

How does the client send a JWT?

The client puts it in the Authorization header as a Bearer token: Authorization: Bearer <token>. The server reads the header, strips the 'Bearer ' prefix, verifies the signature, and trusts the claims inside if the signature checks out.

Why do JWTs expire?

Because a JWT is valid until it expires and cannot be easily revoked, you keep the lifetime short by adding an exp claim. The server rejects any token whose exp is in the past, limiting the damage if a token is stolen. Refresh tokens are used to obtain new access tokens without re-entering a password.

Related lessons