In this article
April 14, 2025
April 14, 2025

How to handle JWT in Python

Everything you need to know to implement and validate JWTs securely in Python — from signing to verifying with JWKS, with code examples and best practices throughout.

Whether you're building a single-page application, a mobile backend, or a microservices architecture, JWTs allow you to authenticate users and transmit information securely between parties without maintaining session state on the server. They're compact, URL-safe, and can be easily verified using digital signatures — making them an ideal choice for many authentication and authorization scenarios.

This tutorial will walk you through the essentials of handling JWTs in Python — from decoding and verifying tokens to securing your APIs with best practices. We'll look at how to use the popular PyJWT library to handle token validation, manage claims like expiration and audience, and safely extract user information from JWTs.

If you are tired of copying JWT code from StackOverflow and want to finally understand what’s actually happening and how to handle JWTs properly in your Python apps, keep reading.

JWT 101

A JSON Web Token (JWT) is a compact, URL-safe token used for securely transmitting information between two parties. Typically, JWTs are used for authentication purposes, allowing a server to verify the identity of a user without needing to store session information.

A JWT consists of three parts: the header, the payload, and the signature:

  • The header typically specifies the algorithm used to sign the token (e.g., HMAC, RSA, or ECDSA) and the type of token.
  • The payload contains the claims or the data — such as user ID, roles, or permissions — that the token is encoding.
  • The signature is the most critical part, ensuring the token's integrity and confirming that it was issued by a trusted source.

JWTs are protected via JSON Web Signature (JWS). JWS uses a signature algorithm and is used to share data between parties when confidentiality is not required. This is because claims within a JWS can be read as they are simply base64-encoded (but carry with them a signature for authentication).Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.

Prerequisites

To work with JWTs in Python, you'll need the PyJWT, a popular library for creating and verifying JWTs:

  
pip install pyjwt
  

Generating your keys

First, you need to have a set of cryptographic keys in order to sign your tokens.

In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them move along to the next section.

!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for secure, distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!

There are many ways to generate your keys. You could generate your keys using OpenSSL and save them as raw PEM files that your code would read.

  
# Generate private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# Extract the public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

However, this is not a best practice. Instead, you should use JSON Web Key Sets (JWKS), especially in distributed or cloud environments.

!!JWKS simplifies key rotation by allowing services to fetch the latest keys from a central endpoint, making updates easier and reducing the risk of errors. In contrast, PEM files require manual distribution and updates, which can be cumbersome in large systems. JWKS also centralizes key distribution, ensuring that all services or clients always have the correct keys without the need for constant manual updates.!!

If you are using a third-party identity provider (like WorkOS), they automatically generate and expose a JWKS endpoint for you. This allows clients to dynamically fetch the public keys needed for JWT verification without you having to manage the keys manually. WorkOS offers a public JWKS endpoint (https://api.workos.com/sso/jwks/your-client_id) that clients can use to retrieve keys to validate JWTs. These services handle key rotation, expiration, and key distribution automatically.

If you're not using a third-party identity provider and want to create and manage your own JWKS in Python, there are several steps you can follow to ensure you're doing so securely and effectively. Here’s a high-level overview:

  1. Generate a key pair (public and private keys). You can generate a key pair using a library like cryptography.
  2. Create a JWKS endpoint: You'll need to expose the public keys as part of a JWKS endpoint. This endpoint (/well-known/jwks.json) will serve the JSON Web Key Set, which clients and services can use to validate JWTs.
  3. Handle key rotation and management: Key rotation is an important part of managing your JWKS. Periodically generating new key pairs and updating the JWKS helps to maintain security. For example, you can rotate keys every few months or whenever you suspect key compromise. When you rotate keys, you can add a new key to your JWKS and mark the old one as inactive. Use a key identifier (kid) to distinguish between active and inactive keys.
  4. Secure your private keys: It's crucial to keep your private keys secure. They should never be exposed through your API or any public endpoint. Store them securely, for instance, in an encrypted file or a secure EKM like WorkOS Vault.

!!If you need something fast for a proof-of-concept, you can use a tool like mkjwk.org to generate a JWK.!!

Here’s how to generate an RSA key pair using cryptography.

First, install the dependency:

  
pip install cryptography
  

Then, use this code to generate your keys:

  
import json
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes

# Generate RSA private key
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
    backend=default_backend()
)

# Generate RSA public key
public_key = private_key.public_key()

# Export public key to JWKS format
public_numbers = public_key.public_numbers()
jwks = {
    "keys": [
        {
            "kty": "RSA",
            "kid": "1",  # You can add any unique identifier here
            "use": "sig",  # The key is used for signing
            "n": public_numbers.n,
            "e": public_numbers.e,
        }
    ]
}

# Convert JWKS to JSON
jwks_json = json.dumps(jwks, separators=(',', ':'))

# Print the JWKS
print("JWKS:", jwks_json)
  

If you are using a vault to store your private key, now is the time to do it. For WorkOS Vault the API call looks like this:

  
curl "https://api.workos.com/vault/v1/kv" \
     -X POST \
     -H "Authorization: Bearer sk_example_123456789" \
     -H "Content-Type: application/json" \
     --data-raw \
     '{
       "name": "my-private-key",
       "value": "my secret value",
       "key_context": {
         "organization_id": "my-org-id"
       }
     }'
  

The response will contain an Id which you can use whenever you want to retrieve the value in order to sign a JWT:

  
curl "https://api.workos.com/vault/v1/kv/secret_51B0AC67C2FB4247AC5ABDDD3C701BDC" \
    -H "Authorization: Bearer sk_example_123456789"
  

For more on this, see the WorkOS Vault API.

Creating a JWT in Python

Once you have your RSA keys, create a token and sign it using the private key. In this example, for simplicity reasons, we are reading the private key from a pem file. Remember that the best practice here is to use a key vault.

  
import jwt
import datetime

# Load your RSA private key (for signing)
with open('private_key.pem', 'r') as f:
    private_key = f.read()

# Create a payload with standard and custom claims
payload = {
    'username': 'john_doe',
    'roles': ['admin', 'editor'],            # Custom claim: list of user roles
    'email_verified': True,                  # Custom claim: email verification status
    'department': 'engineering',             # Custom claim: user's department
    'feature_flags': {
        'beta_access': True,
        'dark_mode': False
    },                                       # Custom claim: feature toggles
    'sub': 123,
    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),  # Expiration time
    'iat': datetime.datetime.utcnow(),  # Issued at time
    'nbf': datetime.datetime.utcnow(),  # Not before time
    'iss': 'my_issuer',  # Issuer
    'aud': 'my_audience'  # Audience
}

# Sign the JWT with the private key using RS256 algorithm
encoded_jwt = jwt.encode(payload, private_key, algorithm='RS256')

print("Encoded JWT (signed):", encoded_jwt)
  

This snippet does the following:

  1. Reads the private key from private_key.pem
  2. Creates the payload of the JWT using various standard and custom claims (more on this in the next section).
  3. Uses the jwt.encode() function from the PyJWT  library to sign the payload. The private_key is used to sign the token with the RS256 algorithm.
  4. Prints the signed JWT.

The output will look like this:

  
Encoded JWT (signed):eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFhYjAzNDVjYzFkZmQ5NTljODIwZjBlZGZiMDMxZDI4In0.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obl9kb2UiLCJyb2xlcyI6WyJhZG1pbiIsImVkaXRvciJdLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVwYXJ0bWVudCI6ImVuZ2luZWVyaW5nIiwiZmVhdHVyZV9mbGFncyI6eyJiZXRhX2FjY2VzcyI6dHJ1ZSwiZGFya19tb2RlIjpmYWxzZX0sImV4cCI6MTc0NDMwNjMyNSwiaWF0IjoxNzQ0MzA2MzI1LCJuYmYiOjE3NDQzMDYzMjUsImlzcyI6Im15X2lzc3VlciIsImF1ZCI6Im15X2F1ZGllbmNlIn0.GMm4kr6gwgokyPoxIiFX249YY1PjN2T9E2_MeGC1h2RhvU6YESby-eiMZeFYtl6VwxjUuBqR-lfPyQJhczSoKfsEykA3fzvfLjWwK9bK2VjpOxXsW_d4XeAy4HjL-iPYwpfh2RmxcU6xX1DluW2BKbueIbC3KP3kllr4RrfNQGXDezQ9CvE3TjGlh0ptmqQGQLPbDHI4NfM9wJ9f0gD1DEgffJwSzBwY7CYbJhxR58VFgtPrY94mApJHS_8ehf7Os6y8zjGTjtCYIYMzvo-Jajq1o8OTgNpvrx06DK7kkMMqeeAlrmFo71rYUt1F7vus4iA-75WAN5AQfJOl8OM8KQ
  

Once the app has the JWT, it can use it to authenticate itself when making requests to another app or API—typically by including it in the Authorization header as a Bearer token. This is a standard, defined in RFC 6750 for OAuth 2.0. It keeps tokens separate from cookies or other headers and it supports token rotation and future flexibility(if you ever switch to another auth method, like Basic, MAC, or OAuth2, clients don’t need to change much).

The Bearer prefix tells the server: “This is a bearer token — anyone who has it can use it, no need for anything else.”

Setting the token as Bearer looks like this:

  
import requests

# Your JWT token (usually retrieved from login/auth flow)
jwt_token = "YOUR_JWT_HERE"

# Target API endpoint
url = "https://api.example.com/protected/resource"

# Set the Authorization header with the Bearer token
headers = {
    "Authorization": f"Bearer {jwt_token}",
    "Content-Type": "application/json"
}

# Make the GET request
response = requests.get(url, headers=headers)

# Handle the response
if response.status_code == 200:
    data = response.json()
    print("Success! Data returned:")
    print(data)
else:
    print(f"Request failed with status code {response.status_code}")
    print(response.text)
  

Adding standard and custom claims

The payload of each JWT consists of claims. These are pieces of information that provide details about the user and can be either standard (as defined by RFC 7519) or custom. Standard claims serve common use cases for identifying the token, setting validity, and establishing trust, while custom ones are created by each app to add specific context or permissions.

In the example we used, we added to our JWT the following standard claims:

  • sub: subject — what the token is about (this is the user’s unique identifier)
  • exp: expiration time — when the token expires.
  • iat: issued at — when the token was issued.
  • nbf: not before — when the token becomes valid.
  • iss: issuer — who issued the token.
  • aud: audience — who the token is intended for.

These claims are technically optional, but several of them are very important during the token validation process, which we will see later.

We also added the following custom claims:

  • username: the user’s username for UI display.
  • roles: the user’s assigned permissions so the app knows what the user can see and do.
  • email_verified: boolean flag for whether the user has verified their email or not.
  • department: the department the user belongs to.
  • feature_flags: dictionary of enabled experimental features.

Custom claims let your application logic leverage token data directly without needing extra DB lookups (but balance that with token size and security).

Decoding a JWT

The app or API that is on the receiving end needs to verify the JWT’s signature using the corresponding public key, and then validate the token's claims (like expiration, audience, etc.).

In our example, we use RS256, which means that the receiving app needs to know the public key. The app will typically receive the public key in one of these ways:

  • Directly from a .pem file or env variable
  • Fetched from a JWKS (JSON Web Key Set) endpoint, like: https://auth.example.com/.well-known/jwks.json

Here’s a basic example that fetches the public key from a JWKS using PyJWKClient:

  
import jwt
from jwt import PyJWKClient

# Example JWT from client
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

# If using a JWKS endpoint:
jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwk_client = PyJWKClient(jwks_url)
# This pulls the correct public key based on the `kid` in the token header
public_key = jwk_client.get_signing_key_from_jwt(token).key

try:
    # Decode and verify the JWT
    decoded = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        audience="your-api-audience",
        issuer="https://auth.example.com/",
    )
    print("JWT is valid:", decoded)
except jwt.ExpiredSignatureError:
    print("Token has expired")
except jwt.InvalidTokenError as e:
    print("Invalid token:", str(e))
  

If you have a local PEM public key file instead:

  
with open("public_key.pem", "r") as f:
    public_key = f.read()

decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"]
)
  

When using jwt.decode(), and providing the key + algorithm, the library:

  • Parses the header and verifies the alg. Since we specify in the input that the algorithm should be RS256, if the header contains a different alg (like HS256 or none), PyJWT will reject the token.
  • Uses the public key to verify the signature.

About the kid claim

You should only care about this claim if, instead of using one static public key, you are using JWKS with rotating keys.

The kid is a key identifier that might appear in the JWT header like this:

  
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123"
}
  

It tells your app which public key (from a set of keys) should be used to verify the signature.

This is used when your authentication provider uses key rotation — i.e., they publish multiple public keys at a JWKS endpoint, and the JWT header includes kid to tell you which one was used to sign it.

For example, let’s say your auth server exposes its JWKS at: https://auth.example.com/.well-known/jwks.json.

That JSON contains an array of public keys, each with a kid:

  
{
  "keys": [
    {
      "kty": "RSA",
      "alg": "RS256",
      "kid": "abc123",
      "n": "...",
      "e": "..."
    },
    {
      "kty": "RSA",
      "alg": "RS256",
      "kid": "xyz789",
      "n": "...",
      "e": "..."
    }
  ]
}
  

When your app receives a JWT, it extracts the kid from the header, it looks up the matching public key in the JWKS, and it uses that key to verify the signature.

If you're using PyJWT, the easiest way to do this is with PyJWKClient:

  
from jwt import PyJWKClient

jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwk_client = PyJWKClient(jwks_url)

# This pulls the correct public key based on the `kid` in the token header
public_key = jwk_client.get_signing_key_from_jwt(token).key

decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="your-audience",
    issuer="https://auth.example.com/"
)
  

PyJWKClient:

  • Fetches the JWKS
  • Extracts kid from header
  • Matches the key
  • Verifies the signature

If you want to access the kid value, to log it or alert someone, you can get it like this:

  
import jwt

header = jwt.get_unverified_header(token)
print("Token signed with key ID:", header.get("kid"))
  

Verifying a JWT

After the signature has been verified, you need to validate the claims. This includes both the standard and the custom ones. For the standard claims, PyJWT does all of the job for you.

All we need to do is pass the expected audience and issuer information. If they don’t match with the information included in the JWT, the library will throw an exception:

  
jwt.decode(
    token,
    public_key,
    algorithms=['RS256'],
    audience='my_audience',
    issuer='my_issuer'
)
  

This call to jwt.decode:

  • Verifies the signature
  • Checks exp (expiration)
  • Checks nbf (not before)
  • Checks aud (audience)
  • Checks iss (issuer)

The library will throw different exceptions depending on what went wrong, and you can handle each one the way you want:

  
import jwt

try:
    decoded_token = jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience="my_audience",
        issuer="my_issuer"
    )
    print("Token is valid:", decoded_token)

except jwt.ExpiredSignatureError:
    print("❌ Token has expired")

except jwt.InvalidIssuerError:
    print("❌ Invalid issuer")

except jwt.InvalidAudienceError:
    print("❌ Invalid audience")

except jwt.InvalidSignatureError:
    print("❌ Invalid signature")

except jwt.DecodeError:
    print("❌ Decode error (bad formatting, etc.)")

except jwt.InvalidTokenError as e:
    # Catch-all for anything else not covered above
    print("❌ Invalid token:", str(e))
  

If you're building a REST API, you will want to wrap this in a function and raise specific exceptions or return HTTP responses accordingly.

For example, this is how this would look like for a Flask app:

  
from flask import Flask, request, jsonify
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidIssuerError, InvalidAudienceError, InvalidTokenError

app = Flask(__name__)

# Public key (you'll typically load this from a file or environment variable)
# For demonstration, we'll assume it's a RSA public key in PEM format
with open("public_key.pem", "r") as f:
    public_key = f.read()

# JWT verification decorator
def jwt_required(f):
    def decorator(*args, **kwargs):
        token = request.headers.get('Authorization')

        if token is None:
            return jsonify({"error": "Authorization token is missing"}), 401

        # Remove "Bearer " from the token if present
        token = token.replace("Bearer ", "")

        try:
            # Decode and verify JWT with the public key
            decoded_token = jwt.decode(
                token,
                public_key,
                algorithms=['RS256'],
                audience="my_audience",   # Your expected audience
                issuer="my_issuer"        # Your expected issuer
            )
            # Attach the decoded token to the request context if needed
            request.decoded_token = decoded_token
            return f(*args, **kwargs)

        except ExpiredSignatureError:
            return jsonify({"error": "Token has expired"}), 401
        except InvalidIssuerError:
            return jsonify({"error": "Invalid issuer"}), 401
        except InvalidAudienceError:
            return jsonify({"error": "Invalid audience"}), 401
        except InvalidTokenError:
            return jsonify({"error": "Invalid token"}), 401
        except jwt.DecodeError:
            return jsonify({"error": "Token could not be decoded"}), 400
        except Exception as e:
            return jsonify({"error": str(e)}), 500

    return decorator


# Example protected route
@app.route('/protected', methods=['GET'])
@jwt_required
def protected_route():
    # Access the decoded token (if needed)
    decoded_token = request.decoded_token
    return jsonify({
        "message": "This is a protected route",
        "user_id": decoded_token["user_id"],
        "roles": decoded_token["roles"]
    })

# Simple route to test non-protected access
@app.route('/')
def index():
    return jsonify({"message": "Public route accessible without token"})

if __name__ == '__main__':
    app.run(debug=True)
  

Handling custom claims

Besides always validating the standard claims for optimal security, you will also want to validate the standard ones before using them. Things like roles, email_verified, or department:

  
if not payload.get("email_verified"):
    raise Exception("Email not verified")

if "admin" not in payload.get("roles", []):
    raise Exception("User is not an admin")
  

This way, you can implement your business logic checks and use the information included in the JWT’s payload.

JWT best practices (Python edition)

  • Always verify the signature. DO NOT decode JWTs without verifying the signature. Always specify the algorithms parameter and provide the correct key.
  • Use asymmetric signing (RS256 or ES256). Prefer RS256 (asymmetric signing) over HS256 (shared secret), especially in distributed systems. It separates concerns: the auth server signs, your apps verify.
  • Validate critical claims. Always validate:
    • exp (Expiration)
    • nbf (Not Before)
    • iat (Issued At)
    • iss (Issuer)
    • aud (Audience)
  • Use a JWKS endpoint if available. If your identity provider (like WorkOS) supports JWKS, use it to automatically fetch the correct public key by kid. If not, build one yourself. Make sure that private keys are stored safely and that you properly handle key rotation and management.
  • Enforce Bearer token format. Require the token to come in the Authorization header, in this format: Authorization: Bearer <jwt>. Strip the prefix in your code: token = request.headers.get("Authorization", "").replace("Bearer ", "").
  • Do NOT store sensitive info in the token. Avoid putting PII, passwords, or access secrets in JWTs. JWTs are not encrypted by default — they’re just base64-encoded and signed.
  • Store sensitive info, like private keys, in a vault, instead of in pem files and environment variables.
  • Set short expiration times. Keep exp short (optimally up to 2 minutes for access tokens). Use refresh tokens if you need longer sessions (with secure storage, e.g., HTTP-only cookies).
  • Handle exceptions gracefully. Catch and handle JWT exceptions like ExpiredSignatureError, InvalidIssuerError, etc. Return appropriate HTTP status codes (e.g., 401 Unauthorized, 403 Forbidden).
  • Use HTTPS everywhere. Always require HTTPS in production to prevent token theft via MITM attacks. This is critical if you're passing JWTs in headers or cookies.
  • Modularize JWT logic. Write a verify_jwt(token) utility or decorator (like we did in Flask). Keep it reusable and clean.
  • Log token failures (carefully). Log kid, iss, and failure reasons — but never log full tokens.
  • Test with bad tokens. Always test edge cases: expired tokens, missing claims, wrong alg, tampered payload, etc.

Using WorkOS for auth, SSO, and user identity

While handling JWTs with PyJWT is often necessary for verifying tokens at the API layer, it’s worth considering how those tokens are issued in the first place. If you're building authentication flows — especially ones involving Single Sign-On (SSO), SCIM provisioning, or multi-tenant identityWorkOS can take care of the heavy lifting.

WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, etc.), manage users and directories, and issue secure tokens — all without building a full auth stack from scratch. It's especially useful if you need to support enterprise customers or want to offer a “Login with your company” experience. And it costs nothing for up to 1,000,000 monthly active users.

If you’re tired of stitching together SSO flows or dealing with SAML metadata manually, it’s worth checking out.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.