Introduction to JWT
Introduction to JWT
For the entire module so far, authentication has worked one way. The user signs in, the server stores a record of that session, and the browser carries a small cookie containing the session’s ID. On every request, the server looks the cookie up in its records and recognises the user.
That model works beautifully for browser-based apps. It has one quiet assumption: there is a server with memory, and the client will hold cookies and send them back automatically.
The moment you step outside that assumption — a mobile app, a single-page app talking to an API, a microservice calling another microservice — the assumption breaks. Mobile apps don’t share cookies with browsers. APIs are often stateless by design. Microservices don’t have a shared session store.
That’s the world JWT was built for.
This lesson explains what JWTs are, walks you through building a real JSON API that issues and verifies them, and then sets out where they belong and where they don’t. By the end you’ll have a small, runnable API you can poke at with curl or Postman — and you’ll see firsthand how a token replaces a session.
What “token-based” actually means
Compare the two models side by side:
| Sessions and cookies | Tokens |
|---|---|
| The server stores who’s logged in. | The token itself carries who’s logged in. |
| The cookie holds an ID; the server looks it up. | The token holds the user data, signed. |
| The server can revoke a session by deleting it. | The server cannot easily revoke a token mid-life. |
| Stateful — needs a session store. | Stateless — no lookup needed. |
| Sent automatically by the browser as a cookie. | Sent explicitly by the client in a header. |
In a session-based system, the cookie is a ticket — meaningless on its own, useful only because the server can look it up. (Remember the coat-check analogy from Lesson 1.)
In a token-based system, the token is more like a signed letter. The user carries the letter to every request. The server reads the letter and trusts it — not because it remembers issuing it, but because the signature proves it’s legitimate. The server doesn’t have to remember anything.
This is the central trade-off. Sessions give you control (you can log someone out instantly). Tokens give you scale (no database lookup, no shared state between servers).
What a JWT looks like
JWT stands for JSON Web Token. It’s a string that looks like noise but actually has structure. Here’s a real (truncated) example from the API we’re about to build:
Three blocks of gibberish, separated by dots. That’s the format every JWT follows:
Each block is just base64-encoded. The first two are JSON; the third is a binary signature. If you decode them:
Header — metadata about the token itself:
{
"alg": "HS256",
"typ": "JWT"
}
It says “this is a JWT, signed with HMAC-SHA256.” That’s how the receiver knows what algorithm to use to verify the signature.
Payload — the actual claims about the user:
{
"sub": "1",
"name": "Alice Carter",
"exp": 1739821736
}
sub (subject) is the user’s ID. name is just useful metadata. exp is the expiration time as a Unix timestamp. There can be other fields too — whatever the server chose to include when it issued the token.
Signature — a cryptographic seal:
The signature is computed from the header, the payload, and a secret key only the server knows. When a request comes in carrying this token, the server recomputes the signature using the same key. If they match, the token is genuine. If they don’t — even one character of the payload was tampered with — the signature fails and the token is rejected.
The crucial property: the payload is not encrypted. Anyone with the token can read it (just base64-decode the middle section — paste a JWT into jwt.io to see this in action). The signature only protects against tampering — proving the server issued it and nobody changed it. Never put secrets in a JWT payload.
Walkthrough: build a JWT-protected JSON API
Now let’s see it work. We’ll build a small API — no templates, no browser UI — that registers users, issues tokens on login, and protects two endpoints behind those tokens. You’ll test it from a tool like curl, Postman, Insomnia, or whatever HTTP client you prefer.
This is honest to JWT’s actual purpose. JWTs aren’t usually for browser apps with rendered pages; they’re for backends that serve JSON to other clients (mobile apps, single-page web apps, other services). Let’s match that reality.
Setup
Create a new folder anywhere outside your other projects:
jwt-api-demo/
└── app.py
That’s the whole thing. One file, no templates.
Install the dependencies:
pip install flask flask-sqlalchemy pyjwt
PyJWT is the de-facto library for working with JWTs in Python. We’re using it directly here so the mechanism is visible; production Flask apps often wrap it with Flask-JWT-Extended for convenience.
app.py
The whole API, about 130 lines:
# app.py
import os
from datetime import datetime, timedelta, timezone
from functools import wraps
import jwt
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///jwt_demo.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-only-change-me")
JWT_ALGORITHM = "HS256"
JWT_EXP_HOURS = 1
db = SQLAlchemy(app)
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
with app.app_context():
db.create_all()
def issue_token(user):
"""Build a signed JWT for the given user."""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user.id),
"name": user.name,
"iat": now,
"exp": now + timedelta(hours=JWT_EXP_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def token_required(view_func):
"""Pull the bearer token off the request, verify it, attach the user."""
@wraps(view_func)
def wrapper(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "missing or malformed Authorization header"}), 401
token = auth_header[len("Bearer "):]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return jsonify({"error": "token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
user = User.query.get(int(payload["sub"]))
if user is None:
return jsonify({"error": "user no longer exists"}), 401
return view_func(*args, current_user=user, **kwargs)
return wrapper
@app.route("/api/register", methods=["POST"])
def register():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not name or not email or not password:
return jsonify({"error": "name, email, and password are required"}), 400
if len(password) < 8:
return jsonify({"error": "password must be at least 8 characters"}), 400
if User.query.filter_by(email=email).first():
return jsonify({"error": "account with that email already exists"}), 409
user = User(name=name, email=email, password=generate_password_hash(password))
db.session.add(user)
db.session.commit()
return jsonify({
"message": "account created",
"user": {"id": user.id, "name": user.name, "email": user.email}
}), 201
@app.route("/api/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
user = User.query.filter_by(email=email).first()
if user is None or not check_password_hash(user.password, password):
return jsonify({"error": "invalid email or password"}), 401
token = issue_token(user)
return jsonify({
"message": f"welcome back, {user.name.split()[0]}",
"token": token,
"user": {"id": user.id, "name": user.name, "email": user.email}
}), 200
@app.route("/api/me")
@token_required
def me(current_user):
return jsonify({
"id": current_user.id,
"name": current_user.name,
"email": current_user.email,
"created_at": current_user.created_at.isoformat()
})
@app.route("/api/secret")
@token_required
def secret(current_user):
return jsonify({
"message": f"Hi {current_user.name.split()[0]} 👋 — you reached a protected endpoint.",
"user_id": current_user.id
})
if __name__ == "__main__":
app.run(debug=True)
Let’s walk through the parts that matter.
issue_token(user)
The function that creates a JWT after successful login. The key bit:
payload = {
"sub": str(user.id),
"name": user.name,
"iat": now,
"exp": now + timedelta(hours=JWT_EXP_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
subis the subject — the user’s ID. We stringify it because the JWT spec recommends string identifiers (some libraries enforce this).iatis issued at — when the token was created.expis expires at — when the token stops being valid. PyJWT will automatically reject tokens past this time.nameis just a convenience claim. The token’s holder can read it without making a server request.
jwt.encode(payload, secret, algorithm=...) returns the dotted three-segment string we saw above. That string is the session.
token_required — the verification decorator
This is the analogue of @login_required from earlier lessons:
@wraps(view_func)
def wrapper(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "missing or malformed Authorization header"}), 401
token = auth_header[len("Bearer "):]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return jsonify({"error": "token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
user = User.query.get(int(payload["sub"]))
if user is None:
return jsonify({"error": "user no longer exists"}), 401
return view_func(*args, current_user=user, **kwargs)
What the decorator does, step by step:
- Read the
Authorizationheader. The convention isBearer <token>— “bearer” means “whoever holds this token has the permissions.” - Strip the
Bearerprefix and pass the rest tojwt.decode. jwt.decodeverifies the signature (using our secret), checksexp, and returns the payload as a Python dict. If any of those fail, it raises an exception we catch and return as 401.- We do one small bit of work that JWT itself can’t do: confirm the user still exists in our database. (If Alice deleted her account ten minutes ago, her old token is still cryptographically valid but should no longer let her in.)
- Pass the user to the view as
current_user.
Why pass
current_uservia kwargs? Because there’s nocurrent_userglobal like Flask-Login provides. We attach it to the view’s arguments instead. In production code you’d typically attach it to Flask’sgobject (from flask import g; g.current_user = user), but the kwarg pattern is more explicit and easier to understand.
The four endpoints
POST /api/register— accepts JSON, creates a user, returns the user (no token yet). 201 on success.POST /api/login— accepts JSON, verifies the password, issues a token. 200 with the token in the body.GET /api/me— protected. Returns the current user’s profile.GET /api/secret— protected. Returns a personalised greeting message.
Notice what’s not here: no session cookie is set anywhere. No session.clear(). No logout endpoint. That’s not an omission — it’s the whole point. The client holds the token. To “log out,” the client just throws the token away.
Run it
From the project folder:
python app.py
The API is now serving on http://127.0.0.1:5000. Open Postman, Insomnia, or your terminal with curl.
Try it — step by step
Walk through these requests in order. I’ll show curl; in Postman you’d do the same thing with the GUI.
1. Register Alice.
curl -X POST http://127.0.0.1:5000/api/register \
-H "Content-Type: application/json" \
-d '{"name": "Alice Carter", "email": "alice@example.com", "password": "sunshine123"}'
You’ll get back:
{
"message": "account created",
"user": {"id": 1, "name": "Alice Carter", "email": "alice@example.com"}
}
2. Try the protected endpoint without a token.
curl http://127.0.0.1:5000/api/me
Response:
{"error": "missing or malformed Authorization header"}
Status 401. The endpoint exists; we just didn’t identify ourselves.
3. Sign in.
curl -X POST http://127.0.0.1:5000/api/login \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "sunshine123"}'
Response:
{
"message": "welcome back, Alice",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...",
"user": {"id": 1, "name": "Alice Carter", "email": "alice@example.com"}
}
That token field is the JWT. Copy it. It’s your proof of identity for the next hour.
4. Look at the token.
Before using it, paste the token into jwt.io. You’ll see the header and payload decoded right there in your browser — sub, name, iat, exp. Verify with your own eyes: the payload is not encrypted, just base64-encoded. Anyone with the token can read it. Only the signature would fail if someone tried to forge a new payload.
5. Use the token on a protected endpoint.
curl http://127.0.0.1:5000/api/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi..."
(Replace the dots with the actual token from step 3.)
Response:
{
"id": 1,
"name": "Alice Carter",
"email": "alice@example.com",
"created_at": "2026-05-26T17:48:56.123456"
}
The API recognised you. Note that it did not need to look up a session record. The signature on the token was enough.
6. Try the secret endpoint.
curl http://127.0.0.1:5000/api/secret \
-H "Authorization: Bearer <your token>"
{
"message": "Hi Alice 👋 — you reached a protected endpoint.",
"user_id": 1
}
7. Try a tampered token.
Take your token. Change a single character anywhere in it. Try again:
curl http://127.0.0.1:5000/api/me \
-H "Authorization: Bearer <tampered token>"
{"error": "invalid token"}
Status 401. The signature didn’t match — jwt.decode raised InvalidTokenError, the decorator returned 401. That’s the cryptographic seal doing its job.
8. Wait an hour, try again.
If you wait until the token’s exp passes (or set JWT_EXP_HOURS = 0.001 and wait a few seconds), the same request returns:
{"error": "token expired"}
The client now has to log in again to get a fresh token. There’s no “session refresh” in this minimal API — production JWT systems usually add a refresh-token endpoint to issue new short-lived access tokens without making the user re-enter their password.
That’s the full lifecycle: get token → use token → token tampered or expired → rejected.
What just happened
Notice what was absent from this walkthrough:
- No session storage. The server has no record of “Alice is logged in.” It only has rows in the users table.
- No cookies. The browser would normally do that work; we did it explicitly by passing the
Authorizationheader. - No login page. Just an endpoint. The “login form” is whatever your client is — Postman, a React frontend, a mobile app.
This is what makes JWT a fundamentally different shape from sessions. The state of “who is signed in” lives entirely on the client side, in the form of the token they’re holding.
What JWTs are good at
- APIs. When your backend is just a REST API serving JSON to a mobile app or a React frontend, JWTs are the natural fit. There’s no cookie jar; there’s no shared server state to consult.
- Microservices. If Service A receives a token from a user and wants to call Service B on the user’s behalf, it just forwards the token. Service B can verify it independently — no shared session store needed.
- Single sign-on (SSO). Tokens issued by one identity provider can be verified by many services as long as they share the public key. This is how “Sign in with Google” works under the hood.
- Mobile apps. Mobile devices don’t have a cookie jar shared with your web app. A token in
Authorization: Beareris the cleanest way to carry identity.
What JWTs are bad at
Honest limitations that often get glossed over:
-
You can’t easily revoke them. A session can be revoked by deleting one row in your session table. A JWT, once issued, is valid until it expires. If a user’s account is compromised and you want to log them out everywhere, you either wait for expiry, rotate the secret key (logging out everyone), or maintain a “denylist” of revoked tokens — which adds back the very server-side state JWTs were meant to avoid.
-
They get big. A session cookie is typically 32–64 bytes. A JWT with a handful of claims is often 500–1000 bytes. That goes on every single request.
-
Storage on the client is tricky.
localStorageis vulnerable to XSS. Cookies are vulnerable to CSRF. Neither option is comfortable for browser apps. There’s no good answer; people use both, and each comes with extra precautions. -
Easy to misuse. Forgetting to verify the signature. Using a weak algorithm. Trusting unverified claims. Allowing the
nonealgorithm. JWT libraries try to make these mistakes hard, but the JWT spec itself has historical footguns.
The honest summary: JWTs aren’t a drop-in upgrade from sessions. They’re a different tool for a different shape of problem.
When to use which
A rough rule:
- Browser app talking to its own server? Use sessions and cookies. It’s what you built in this module. Don’t make it more complicated than it needs to be.
- Mobile app talking to your API? Use JWTs.
- Single-page app (React, Vue) talking to your API? Either works. Sessions are simpler and safer (no
localStorageworries) if your API runs on the same domain. Tokens are common if the API is on a different domain or shared with mobile clients. - Multiple microservices needing to share identity? Tokens.
- You need instant logout / kick-user-out-of-everywhere? Sessions. Or tokens with a denylist (giving up the stateless property).
A common hybrid
Many real systems use both. Sessions for the web frontend; JWTs for the API that mobile clients hit. The user authenticates once, but the transport of identity differs depending on the client.
You’ll see this often. The Books app, if it ever grew a mobile version, would likely keep sessions for the web and add a /api/token endpoint that issues JWTs for mobile clients. Same User model, same password hashing — just two different ways for clients to prove who they are.
Production: Flask-JWT-Extended
The PyJWT-only approach we used in this demo is great for learning because every step is visible. For real Flask APIs, the Flask-JWT-Extended library wraps PyJWT with niceties: a @jwt_required() decorator (no need to roll your own), current_user as a proxy you can import, automatic refresh-token handling, blocklisting support, and the option to carry tokens in cookies instead of headers.
A taste of what the same /api/me endpoint looks like with Flask-JWT-Extended:
from flask_jwt_extended import JWTManager, jwt_required, current_user
@app.route("/api/me")
@jwt_required()
def me():
return jsonify({"id": current_user.id, "name": current_user.name})
Same shape as our hand-rolled decorator, less boilerplate. If you’re building a production API on Flask, this is the path most teams take.
Summary
- Sessions store identity on the server; the cookie is a lookup key.
- JWTs carry identity in the token itself, signed but not encrypted.
- A JWT is three base64-encoded blocks:
header.payload.signature. - Never put secrets in the payload — it’s readable by anyone with the token.
- JWTs scale well across servers but are hard to revoke instantly.
- Use sessions for browser apps; use JWTs for APIs, mobile clients, and microservices.
- The PyJWT library handles encoding and decoding; Flask-JWT-Extended adds Flask integration.
- You just built a working JWT API and tested every step — register, login, protected endpoints, tampered tokens, expired tokens.
Outcome
You understand what JWTs are, you’ve issued and verified them with your own hands, and you know what they cost as well as what they buy. You can read code that uses them and recognise the patterns — and you can also resist the urge to reach for them when sessions would do the job better. Next lesson, we look at who issues identity in the first place: OAuth and “Sign in with Google.”