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
- The three parts: header, payload, signature
- How the HMAC signature prevents tampering
- Issuing a token when a user logs in
- Sending a token as 'Authorization: Bearer'
- Verifying a token on a protected route
- Rejecting expired tokens with an exp claim
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 signatureCommon 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.
| Concept | Detail |
|---|---|
| Token shape | header.payload.signature |
| Send it | Authorization: Bearer <token> |
| Protect a route | @jwt_required() |
| Read the user | get_jwt_identity() |
🏆 Mini Challenge: Reject an Expired Token
Add an expiry claim and enforce it on verification.
- Issue a token with an exp in the past
- In verify, compare exp against the current time
- Raise (or return an error) if the token has expired
# 🏆 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
- Previous: CORS & Cross-Origin Requests
- Next: Serialization with Marshmallow — Serialize and validate API data with Marshmallow schemas
- Quick reference: Flask cheat sheet
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.