Securing Flask REST APIs with JSON Web Token Authentication: A Step-by-Step Python Guide

Flask's lightweight design gives you full control over how authentication works in your API -- but that also means you are responsible for every piece of the security stack. Flask-JWT-Extended bridges this gap by providing a battle-tested JWT layer that handles token creation, extraction, refresh, and blocklisting while leaving the rest of your application architecture entirely up to you. This guide walks through building a complete JWT authentication system for a Flask REST API, from password hashing and login endpoints to protected routes and role-based access control.

Flask does not include a built-in authentication system the way Django does. This is by design -- Flask is a micro-framework that lets you choose exactly the tools you need. For JWT-based API authentication, the flask-jwt-extended extension provides the standard set of features: automatic token parsing from headers or cookies, the @jwt_required() decorator for protecting routes, built-in support for access and refresh token pairs, and a blocklist system for handling logout. Under the hood, it uses PyJWT for token encoding and decoding.

Project Setup and Dependencies

Create a new project, set up a virtual environment, and install the required packages.

# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate

# Install dependencies
pip install flask flask-jwt-extended flask-bcrypt flask-sqlalchemy python-dotenv

Create a .env file for your configuration secrets. Generate the JWT secret key with openssl rand -hex 32 and never commit this file to version control.

# .env
JWT_SECRET_KEY=your-64-character-hex-secret-here
SQLALCHEMY_DATABASE_URI=sqlite:///app.db

Configuring Flask-JWT-Extended

Initialize the Flask app, load environment variables, and configure the JWT extension. The JWT_ACCESS_TOKEN_EXPIRES setting controls how long access tokens remain valid -- 30 minutes is a reasonable default for API use.

# app.py
import os
from datetime import timedelta
from dotenv import load_dotenv
from flask import Flask
from flask_jwt_extended import JWTManager
from flask_bcrypt import Bcrypt
from flask_sqlalchemy import SQLAlchemy

load_dotenv()

app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=30)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ[
    "SQLALCHEMY_DATABASE_URI"
]

db = SQLAlchemy(app)
bcrypt = Bcrypt(app)
jwt = JWTManager(app)

Password Hashing with Bcrypt

Define a User model with a hashed password field. Flask-Bcrypt wraps the bcrypt library and provides convenient methods for hashing and verifying passwords. Never store plain-text passwords -- even in development.

# models.py
from app import db, bcrypt

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(
        db.String(80), unique=True, nullable=False
    )
    password_hash = db.Column(
        db.String(128), nullable=False
    )
    role = db.Column(
        db.String(20), nullable=False, default="user"
    )

    def set_password(self, password):
        self.password_hash = bcrypt.generate_password_hash(
            password
        ).decode("utf-8")

    def check_password(self, password):
        return bcrypt.check_password_hash(
            self.password_hash, password
        )
Security Warning

The error message for failed login should always say "Invalid username or password" -- never specify which field was wrong. Revealing whether a username exists lets attackers enumerate valid accounts.

Building the Registration and Login Endpoints

The registration endpoint creates a new user with a hashed password. The login endpoint verifies credentials and returns a JWT access token and a refresh token.

# routes.py
from flask import request, jsonify
from flask_jwt_extended import (
    create_access_token,
    create_refresh_token,
)
from app import app, db
from models import User

@app.route("/register", methods=["POST"])
def register():
    data = request.get_json()
    if User.query.filter_by(
        username=data["username"]
    ).first():
        return jsonify(
            {"error": "Username already exists"}
        ), 409

    user = User(username=data["username"])
    user.set_password(data["password"])
    db.session.add(user)
    db.session.commit()
    return jsonify(
        {"message": "User created successfully"}
    ), 201

@app.route("/login", methods=["POST"])
def login():
    data = request.get_json()
    user = User.query.filter_by(
        username=data["username"]
    ).first()

    if not user or not user.check_password(data["password"]):
        return jsonify(
            {"error": "Invalid username or password"}
        ), 401

    access_token = create_access_token(
        identity=user.username,
        additional_claims={"role": user.role},
    )
    refresh_token = create_refresh_token(
        identity=user.username
    )

    return jsonify(
        access_token=access_token,
        refresh_token=refresh_token,
    ), 200

The additional_claims parameter lets you embed custom data in the JWT payload, such as the user's role. This data travels with every request, so the server can make authorization decisions without a database lookup on each call.

Protecting Routes with @jwt_required

The @jwt_required() decorator is the core of Flask-JWT-Extended's route protection. It automatically extracts the JWT from the Authorization: Bearer header, verifies the signature, checks expiration, and rejects the request with a 401 if anything fails. Inside the protected route, get_jwt_identity() returns the identity you stored in the token (the username), and get_jwt() returns the full set of claims.

from flask_jwt_extended import (
    jwt_required,
    get_jwt_identity,
    get_jwt,
)

@app.route("/profile", methods=["GET"])
@jwt_required()
def profile():
    current_user = get_jwt_identity()
    claims = get_jwt()
    return jsonify(
        username=current_user,
        role=claims.get("role"),
    ), 200

@app.route("/items", methods=["GET"])
@jwt_required()
def get_items():
    # Only authenticated users reach this code
    return jsonify(items=["widget", "gadget", "tool"]), 200
Pro Tip

Use @jwt_required(optional=True) for routes that should work for both authenticated and anonymous users. Inside the route, get_jwt_identity() returns None if no token was provided, letting you adjust the response based on whether the caller is logged in.

Refresh Tokens for Long-Lived Sessions

Access tokens expire quickly (30 minutes in our configuration). Refresh tokens let the client obtain a new access token without re-entering credentials. The refresh endpoint requires the refresh token instead of the access token, which Flask-JWT-Extended handles through the refresh=True parameter.

@app.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh():
    current_user = get_jwt_identity()
    new_access_token = create_access_token(
        identity=current_user
    )
    return jsonify(access_token=new_access_token), 200

Implementing Logout with Token Blocklisting

JWTs are stateless -- the server does not track which tokens it has issued. This creates a challenge for logout: you cannot "invalidate" a token since there is no server-side session to destroy. Flask-JWT-Extended solves this with a blocklist. When a user logs out, you add the token's unique identifier (jti claim) to a blocklist. On every subsequent request, the extension checks whether the token's jti is in the blocklist and rejects it if so.

# In production, use Redis instead of a Python set
BLOCKLIST = set()

@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
    jti = jwt_payload["jti"]
    return jti in BLOCKLIST

@app.route("/logout", methods=["DELETE"])
@jwt_required()
def logout():
    jti = get_jwt()["jti"]
    BLOCKLIST.add(jti)
    return jsonify(
        {"message": "Token revoked successfully"}
    ), 200
Note

An in-memory Python set works for development but is lost on restart and not shared across multiple server processes. In production, store the blocklist in Redis with a TTL matching your token's expiration time so entries clean themselves up automatically.

Adding Role-Based Access Control

With the role claim already in the JWT, you can create a decorator that checks the user's role before allowing access to a route. This builds on top of @jwt_required() and adds one more layer of verification.

from functools import wraps

def role_required(required_role):
    """Decorator that restricts access to a specific role."""
    def decorator(fn):
        @wraps(fn)
        @jwt_required()
        def wrapper(*args, **kwargs):
            claims = get_jwt()
            if claims.get("role") != required_role:
                return jsonify(
                    {"error": "Insufficient permissions"}
                ), 403
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@app.route("/admin/users", methods=["GET"])
@role_required("admin")
def admin_list_users():
    users = User.query.all()
    return jsonify(
        users=[
            {"id": u.id, "username": u.username, "role": u.role}
            for u in users
        ]
    ), 200

This pattern composes naturally with Flask's routing system. The @role_required("admin") decorator handles both JWT verification and role checking in a single line, keeping your route handlers focused on business logic.

Validating Input on Registration and Login

The registration and login endpoints shown earlier accept whatever the client sends without checking it. That is a problem. If data["username"] or data["password"] is missing, the route throws an unhandled KeyError. If the password is a single character, it gets hashed and stored. If the username is 10,000 characters long, it hits the database. None of these outcomes are acceptable in production.

Add explicit validation before any business logic runs. At a minimum, check that both fields exist, enforce a reasonable length range, and set a password complexity floor. For larger projects, a library like marshmallow or pydantic can handle this declaratively, but for a focused authentication module, manual checks are clear and self-contained.

import re

def validate_registration(data):
    """Return an error message string or None if valid."""
    username = data.get("username", "").strip()
    password = data.get("password", "")

    if not username or not password:
        return "Username and password are required"
    if len(username) < 3 or len(username) > 80:
        return "Username must be 3-80 characters"
    if len(password) < 10:
        return "Password must be at least 10 characters"
    if not re.search(r"[A-Z]", password):
        return "Password must contain an uppercase letter"
    if not re.search(r"[0-9]", password):
        return "Password must contain a digit"
    return None

@app.route("/register", methods=["POST"])
def register():
    data = request.get_json(silent=True)
    if not data:
        return jsonify({"error": "Request body must be JSON"}), 400

    error = validate_registration(data)
    if error:
        return jsonify({"error": error}), 400

    if User.query.filter_by(
        username=data["username"].strip()
    ).first():
        return jsonify(
            {"error": "Username already exists"}
        ), 409

    user = User(username=data["username"].strip())
    user.set_password(data["password"])
    db.session.add(user)
    db.session.commit()
    return jsonify(
        {"message": "User created successfully"}
    ), 201
Security Warning

Validation error messages on the /register endpoint can be specific because the user is creating an account and needs to know what to fix. On the /login endpoint, always return the same generic error regardless of whether the username or password was wrong -- specific messages let attackers enumerate valid accounts.

Rate Limiting Authentication Endpoints

Without rate limiting, an attacker can send thousands of login requests per second to brute-force passwords or flood the registration endpoint with junk accounts. Flask-Limiter integrates with Flask's routing system and lets you set per-endpoint limits using a simple decorator.

pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"],
    storage_uri="redis://localhost:6379",
)

@app.route("/login", methods=["POST"])
@limiter.limit("5 per minute")
def login():
    # ... existing login logic ...
    pass

@app.route("/register", methods=["POST"])
@limiter.limit("3 per minute")
def register():
    # ... existing registration logic ...
    pass

The key_func=get_remote_address parameter identifies clients by IP address. The storage_uri points to Redis so rate limit counters persist across server restarts and are shared across multiple worker processes. When a client exceeds the limit, Flask-Limiter returns a 429 Too Many Requests response automatically. Keep authentication limits tight -- five login attempts per minute is generous enough for real users and restrictive enough to slow down automated attacks.

Where Should the Client Store the Token?

The guide so far has focused on the server side: issuing tokens, verifying them, revoking them. But the client has to store those tokens somewhere between requests, and where it stores them has direct security consequences. There are three common options, and each one trades off between convenience and exposure to different attack types.

localStorage is the easiest to implement -- the client saves the token with localStorage.setItem() and reads it back for every request. The problem is that localStorage is fully accessible to JavaScript. If an attacker exploits a cross-site scripting (XSS) vulnerability anywhere in your frontend, they can read the token directly and exfiltrate it to their own server. The OWASP community recommends against storing session identifiers in local storage for this reason.

HttpOnly cookies are not accessible to JavaScript at all, which removes the XSS exfiltration vector. Flask-JWT-Extended has built-in support for storing tokens in cookies -- set JWT_TOKEN_LOCATION to ["cookies"] and the extension handles setting and reading the cookie automatically. The tradeoff is that cookies are sent with every request to the matching domain, which opens the door to cross-site request forgery (CSRF). Flask-JWT-Extended addresses this with a built-in double-submit CSRF protection mechanism that you enable with JWT_COOKIE_CSRF_PROTECT = True.

# Cookie-based JWT configuration
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
app.config["JWT_COOKIE_SECURE"] = True        # HTTPS only
app.config["JWT_COOKIE_CSRF_PROTECT"] = True   # Enable CSRF protection
app.config["JWT_COOKIE_SAMESITE"] = "Lax"      # Restrict cross-site sending

The third option -- and the one that provides the strongest combination of protections -- is storing the short-lived access token in memory (a JavaScript variable) and the longer-lived refresh token in an HttpOnly cookie. The access token never touches persistent browser storage, so XSS cannot exfiltrate it. The refresh token is shielded from JavaScript by the HttpOnly flag. When the access token expires or is lost on a page refresh, the client hits the /refresh endpoint and the browser sends the cookie automatically. This hybrid approach requires the most coordination between frontend and backend, but it limits the blast radius of both XSS and CSRF.

Pro Tip

Regardless of which storage method you choose, always set JWT_COOKIE_SECURE = True in production so tokens are only sent over HTTPS. A token transmitted over plain HTTP can be intercepted by anyone on the network.

Handling Token Compromise

If a JWT is stolen before it expires, the attacker can use it freely until expiration -- the server cannot tell the difference between the legitimate user and the attacker. The blocklist handles voluntary logout, but token theft is involuntary. There are several layers you can add to reduce the window of exposure and detect compromise early.

First, keep access token lifetimes as short as your application can tolerate. A 15-minute window limits how long a stolen token is useful. Second, implement refresh token rotation: every time the client uses a refresh token to get a new access token, issue a new refresh token as well and invalidate the old one. If an attacker replays a refresh token that has already been rotated out, you know the token was stolen -- invalidate the entire family of tokens for that user and force re-authentication.

@app.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh():
    current_user = get_jwt_identity()
    old_jti = get_jwt()["jti"]

    # Rotate: blocklist the old refresh token
    BLOCKLIST.add(old_jti)

    new_access_token = create_access_token(
        identity=current_user
    )
    new_refresh_token = create_refresh_token(
        identity=current_user
    )
    return jsonify(
        access_token=new_access_token,
        refresh_token=new_refresh_token,
    ), 200

Third, consider adding an /admin/revoke-user endpoint that blocklists all tokens for a given user by storing a "revoked after" timestamp. In the token_in_blocklist_loader callback, compare the token's iat (issued at) claim against that timestamp -- any token issued before the revocation time is rejected, even if its jti was never individually blocklisted.

Customizing Error Responses

Flask-JWT-Extended returns default error messages when authentication fails, but these are generic strings that may not match your API's response format. If the rest of your API returns errors as {"error": "description"}, the JWT extension's default {"msg": "..."} format will be inconsistent. Register custom error handlers to normalize the response shape across your entire API.

@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
    return jsonify(
        {"error": "Token has expired"}
    ), 401

@jwt.invalid_token_loader
def invalid_token_callback(error_string):
    return jsonify(
        {"error": "Invalid token"}
    ), 401

@jwt.unauthorized_loader
def missing_token_callback(error_string):
    return jsonify(
        {"error": "Authorization token is required"}
    ), 401

@jwt.revoked_token_loader
def revoked_token_callback(jwt_header, jwt_payload):
    return jsonify(
        {"error": "Token has been revoked"}
    ), 401

Each callback corresponds to a specific failure mode: expired signature, malformed token, missing header, and blocklisted token. By registering all four, you control exactly what the client sees in every authentication failure scenario. This also prevents the extension from leaking internal details in error messages -- the client gets a consistent, predictable error format without any information that would help an attacker understand the server's internals.

CORS Configuration for API Consumers

If your Flask API serves a frontend running on a different domain or port -- which is the case for any single-page application during development and often in production -- the browser will block the request unless the server explicitly allows the origin. This is the Cross-Origin Resource Sharing (CORS) policy enforced by browsers. Without configuring it, your JWT-authenticated API will return responses that the browser silently discards.

pip install flask-cors
from flask_cors import CORS

# Allow specific origins -- never use "*" with credentials
CORS(
    app,
    origins=["https://yourfrontend.com"],
    supports_credentials=True,
    allow_headers=["Content-Type", "Authorization"],
    methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
)

The supports_credentials=True parameter is required if you are using cookie-based JWT storage, because the browser will not send cookies cross-origin unless the server responds with Access-Control-Allow-Credentials: true. When credentials are enabled, the origins parameter must be an explicit list -- browsers reject the wildcard * when credentials are involved. If you are using header-based tokens with Authorization: Bearer, the allow_headers list must include Authorization so the browser's preflight check does not block authenticated requests.

Security Warning

Setting origins="*" in production allows any website to make authenticated requests to your API. Always restrict origins to the specific domains your frontend runs on. An overly permissive CORS policy can allow an attacker's page to issue requests on behalf of a logged-in user.

Key Takeaways

  1. Use Flask-JWT-Extended for Flask JWT authentication: It provides @jwt_required(), automatic token parsing, refresh token support, and a blocklist system out of the box -- saving significant boilerplate over using raw PyJWT.
  2. Hash passwords with bcrypt and never store them in plain text: Flask-Bcrypt wraps the bcrypt library for convenient hashing and verification. Every password should be hashed before it reaches your database, even in development.
  3. Issue both access and refresh tokens: Access tokens should be short-lived (15-30 minutes). Refresh tokens let the client obtain new access tokens without re-authenticating. Store refresh tokens securely and consider implementing rotation.
  4. Implement logout using token blocklisting: Since JWTs are stateless, you need a server-side blocklist (ideally in Redis) to revoke tokens on logout. Register a @jwt.token_in_blocklist_loader callback to check the blocklist on every request.
  5. Add role-based access with custom decorators: Embed a role claim in the JWT payload during login. Create a @role_required() decorator that wraps @jwt_required() and checks the role claim before the route handler executes.
  6. Validate every input before processing it: Check that required fields exist, enforce length limits on usernames, and require password complexity minimums. Unvalidated input leads to unhandled exceptions and weak credentials in your database.
  7. Rate limit authentication endpoints: Use Flask-Limiter to restrict login and registration to a small number of requests per minute per IP. This is the primary defense against brute-force password attacks and registration spam.
  8. Choose token storage based on your threat model: HttpOnly cookies prevent XSS exfiltration but require CSRF protection. localStorage is simple but exposed to any XSS vulnerability. The hybrid approach -- access tokens in memory, refresh tokens in HttpOnly cookies -- provides the strongest combined defense.
  9. Plan for token compromise: Rotate refresh tokens on every use. Keep access token lifetimes short. Build an admin revocation mechanism that can invalidate all tokens for a user by timestamp, not just individual jti values.
  10. Configure CORS explicitly for your frontend origins: Never use a wildcard origin with credentials. Include Authorization in allow_headers for Bearer token flows, and set supports_credentials=True for cookie-based flows.

Flask's micro-framework philosophy means you assemble your own authentication stack -- and Flask-JWT-Extended makes that assembly clean and secure. The login endpoint issues tokens, the @jwt_required() decorator protects routes, the refresh endpoint extends sessions, the blocklist handles logout, and custom decorators add role-based restrictions. But authentication is only the first layer. Input validation stops bad data at the gate. Rate limiting prevents brute-force attacks. Token storage decisions determine your exposure to XSS and CSRF. CORS configuration controls which origins can reach your API. Each piece is independent, testable, and composable, which is exactly how Flask is designed to work -- but skipping any one of them leaves a gap that undermines the rest.