PyFu

JWT for Authentication and Authorization in Flask

Python Web Development Frameworks

Before

JWT in Flask Applications

JWT can be integrated into Flask applications to implement stateless authentication where the client holds its own authentication state through tokens, eliminating the need for server-side session storage.

In this simple Flask application, we demonstrate how users can authenticate using a username and password, receive a signed JWT token upon successful authentication, and access protected resources by presenting that token on subsequent requests.

from flask import Flask, request, jsonify

app = Flask(__name__)

# In-memory fake user database
USERS = {
    'askar': 'pyfu123',
    'admin': 'pyfu123'
}

@app.route('/login', methods=['POST'])
def login():
    data = request.json
    username = data.get('username')
    password = data.get('password')

    if username in USERS and USERS[username] == password:
        token = generate_token(username)
        return jsonify({'token': token})

    return jsonify({'message': 'Invalid credentials'}), 401

@app.route('/protected', methods=['GET'])
def protected():
    auth_header = request.headers.get('Authorization')

    if not auth_header or not auth_header.startswith('Bearer '):
        return jsonify({'message': 'Missing or invalid Authorization header'}), 401

    token = auth_header.split(' ')[1]
    payload = decode_token(token)

    if not payload:
        return jsonify({'message': 'Invalid or expired token'}), 401

    return jsonify({'message': f'Welcome {payload["username"]}! This is a protected resource.'})

When the user sends a POST request to /login with valid credentials, a token is generated with a 30-minute expiration and returned in the response.

The client then includes this token in the Authorization header using the Bearer schema when requesting protected resources.

The /protected route reads the token from the header, decodes it, and verifies both its integrity and expiration. If verification passes, the user gains access to the protected resource.

To improve code reusability and security checks across multiple endpoints, a custom decorator can be written to handle JWT verification.

from functools import wraps
from flask import request, jsonify

def jwt_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization')

        if not auth_header or not auth_header.startswith('Bearer '):
            return jsonify({'message': 'Missing or invalid Authorization header'}), 401

        token = auth_header.split(' ')[1]
        payload = decode_token(token)

        if not payload:
            return jsonify({'message': 'Invalid or expired token'}), 401

        return f(payload, *args, **kwargs)
    return decorated

With this decorator, any protected route can now simply declare the requirement for a valid token while having direct access to the decoded payload:

@app.route('/dashboard')
@jwt_required
def dashboard(payload):
    return jsonify({'message': f'Hello {payload["username"]}, this is your dashboard.'})

This pattern allows you to build scalable, stateless, token-based authentication systems directly in Flask while maintaining full control over the token logic.

We can login using the user askar credentials via curl by sending a valid JSON payload to the /login endpoint as the following:

curl -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username": "askar", "password": "pyfu123"}'

This will return a valid JWT token for the user askar:

hackpad :: ~ » curl -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username": "askar", "password": "pyfu123"}'
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFza2FyIiwiZXhwIjoxNzUwMDc3NDkzfQ.tysfMTyhSG2AXq-Pat3HC-tNmFhNiHEHXhMZpgvOj4c"
}

Now if we take the retrieved JWT token and pass it to the /protected endpoint using the Authorization header, the request will pass since the token is valid:

hackpad :: ~ » curl http://localhost:5000/protected -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFza2FyIiwiZXhwIjoxNzUwMDc3NDkzfQ.tysfMTyhSG2AXq-Pat3HC-tNmFhNiHEHXhMZpgvOj4c"
{
  "message": "Welcome askar! This is a protected resource."
}

This design is widely adopted in modern web applications where users authenticate once to retrieve a JWT token, and the frontend uses this token for subsequent communication with protected endpoints.

Securing the secret key used to sign tokens is critical. If the secret key is exposed, attackers can generate valid tokens and impersonate users. It is also a good practice to use strong signing algorithms to ensure token integrity.

Using a sufficiently strong and random secret key minimizes the risk of brute-force or dictionary attacks against the token signature. While such attacks are theoretically possible, they are rarely feasible in production environments where secrets are typically auto-generated and follow strong entropy patterns.

Why JWT in Flask matters from an offensive security perspective

When a Flask app hands me a JWT, it is handing me the entire authentication state. The token is the credential, it lives client-side, and the server trusts whatever survives signature verification. That makes the decode path the single most valuable thing I look at, because every weakness in how the token is validated translates directly into impersonation. The wiring shown above, a decode_token helper plus a jwt_required decorator, is exactly where I focus, since one lax verification call lets me mint my own access.

Here is what I look for:

  • Algorithm handling on decode. A decode call that does not pin algorithms=["HS256"] may accept none and skip verification entirely, or accept an attacker-chosen algorithm. See Forging JWTs with the none Algorithm.
  • Weak or guessable signing keys. An HS256 secret like secret, pyfu123, or a value committed to the repo is crackable offline, after which I sign arbitrary tokens. See Cracking Weak JWT Signing Keys.
  • Verification that does not actually fail closed. A decode_token that swallows the exception and returns a truthy value, or a decorator that logs the error and still calls the view, so a forged or expired token passes. See Authentication Bypass via Broken JWT Validation.
  • Trusting claims without re-checking them. Roles, user ids, or admin flags read straight from the payload and used for authorization without confirming the signature gated them, which turns any payload tampering into privilege escalation.
  • Missing expiration or signature checks. Tokens decoded without verifying exp, or read with an unverified decode that never checks the signature at all.

When auditing a Flask JWT setup, read the decode call first and treat the app as fully bypassable until I confirm it pins the algorithm, verifies the signature, and fails closed. Start from Authentication Bypass via Broken JWT Validation for the concrete attack chains.

Mitigation

The secure pattern is to make the decode path strict and fail closed. Always pass an explicit algorithms list to jwt.decode so the server, not the token header, decides which algorithm is acceptable, and never include none. Verify the signature and expiration on every request, sign with a long, random secret pulled from the environment rather than a hard-coded string, and let a failed decode raise so the decorator returns 401 instead of falling through. Re-derive any authorization decision from claims only after the signature has been confirmed.

import os
import jwt
from functools import wraps
from flask import request, jsonify

SECRET = os.environ["JWT_SECRET"]  # long, random, never committed

def decode_token(token):
    # pin the algorithm, verify signature and exp, raise on failure
    return jwt.decode(token, SECRET, algorithms=["HS256"])

def jwt_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return jsonify({"message": "Missing or invalid Authorization header"}), 401
        try:
            payload = decode_token(auth_header.split(" ", 1)[1])
        except jwt.InvalidTokenError:
            return jsonify({"message": "Invalid or expired token"}), 401  # fail closed
        return f(payload, *args, **kwargs)
    return decorated