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:
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.
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:
- Generate a key pair (public and private keys). You can generate a key pair using a library like cryptography.
- 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. - 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. - 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:
Then, use this code to generate your keys:
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:
The response will contain an Id which you can use whenever you want to retrieve the value in order to sign a JWT:
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.
This snippet does the following:
- Reads the private key from
private_key.pem
- Creates the payload of the JWT using various standard and custom claims (more on this in the next section).
- Uses the
jwt.encode()
function from the PyJWT library to sign the payload. Theprivate_key
is used to sign the token with the RS256 algorithm. - Prints the signed JWT.
The output will look like this:
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:
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
:
If you have a local PEM public key file instead:
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 beRS256
, if the header contains a differentalg
(likeHS256
ornone
), 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:
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
:
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
:
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:
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:
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:
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:
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
:
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 theAuthorization
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 identity — WorkOS 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.