Flask 3 + Flask-JWT-Extended access and refresh tokens: working versions (tested 2026-10-15)
Login, protected routes, refresh tokens and custom claims in Flask 3 using Flask-JWT-Extended, with an app factory.
passed3 passed · 0 failed2026-10-15 13:15 UTC- Status
- passed
- Last passed
- 2026-10-15 13:15 UTC
- Tests
- 3 / 3
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38740169402
The code
Exactly the files that passed. Your agent can pull them with get_recipe("flask-jwt-extended-auth") (install), or see them on GitHub.
flask==3.1.3 flask-jwt-extended==4.7.4 pytest==9.1.1
from flask import Flask, jsonify, request
from flask_jwt_extended import (JWTManager, create_access_token, create_refresh_token,
get_jwt, get_jwt_identity, jwt_required)
USERS = {"ada": {"password": "lovelace", "role": "admin"}} # swap for a real store
def create_app(secret="change-me"):
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = secret
JWTManager(app)
@app.post("/login")
def login():
body = request.get_json(silent=True) or {}
user = USERS.get(body.get("username"))
if not user or user["password"] != body.get("password"):
return jsonify(error="invalid_credentials"), 401
claims = {"role": user["role"]}
return jsonify(access=create_access_token(identity=body["username"], additional_claims=claims),
refresh=create_refresh_token(identity=body["username"], additional_claims=claims))
@app.post("/refresh")
@jwt_required(refresh=True)
def refresh():
return jsonify(access=create_access_token(identity=get_jwt_identity(), additional_claims={"role": get_jwt()["role"]}))
@app.get("/admin")
@jwt_required()
def admin():
if get_jwt().get("role") != "admin":
return jsonify(error="forbidden"), 403
return jsonify(user=get_jwt_identity())
return app
from app import create_app
def client():
return create_app("a-long-enough-test-secret-key-123").test_client()
def test_login_and_access():
c = client()
tokens = c.post("/login", json={"username": "ada", "password": "lovelace"}).get_json()
r = c.get("/admin", headers={"Authorization": f"Bearer {tokens['access']}"})
assert r.status_code == 200 and r.get_json() == {"user": "ada"}
def test_refresh_flow():
c = client()
tokens = c.post("/login", json={"username": "ada", "password": "lovelace"}).get_json()
new = c.post("/refresh", headers={"Authorization": f"Bearer {tokens['refresh']}"}).get_json()["access"]
assert c.get("/admin", headers={"Authorization": f"Bearer {new}"}).status_code == 200
# an access token cannot be used to refresh
assert c.post("/refresh", headers={"Authorization": f"Bearer {new}"}).status_code == 422
def test_rejections():
c = client()
assert c.post("/login", json={"username": "ada", "password": "x"}).status_code == 401
assert c.get("/admin").status_code == 401
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Flask 3 app factory + Flask-SQLAlchemy
Flask application factory with Flask-SQLAlchemy 3, a JSON API and pytest fixtures using an in-memory database.
Flask API args parsed and validated with webargs and marshmallow
Flask routes that parse query strings and JSON bodies through marshmallow schemas via webargs, with JSON 422 validation errors, tested offline with the Flask test client.
Flask API with Flask-SQLAlchemy and marshmallow-sqlalchemy, validation errors as 422
A Flask JSON API that loads and dumps SQLAlchemy models through SQLAlchemyAutoSchema, returns validation errors as 422, and is tested offline with the Flask test client on in-memory sqlite.
Flask-Login sessions with Flask-SQLAlchemy users and werkzeug password hashing
Login, login_required redirects, logout and a user_loader built on db.session.get, with the User model in SQLite memory and tests run through the Flask test client.