Validating JWTs in FastAPI (Python)

Before you start, you'll need to make sure you're using a AWS Gateway JWT template with your Descope project. That way, your Descope JWTs will be OIDC compliant with the proper issuer and audience claims.

This guide is a step-by-step walkthrough of how to implement a custom Descope JWT authorizer in a Python FastAPI application.

To implement JWT validation, you'll need a reusable function or callable class that:

  1. Extracts and verifies the JWT from the request's Authorization header
    • Returns 401 Unauthorized if verification fails (missing, malformed, expired, or invalid token)
  2. Enforces required scopes for scoped routes
    • Returns 403 Forbidden if the token lacks necessary scopes

This guide shows you how to create those reusable functions and classes, and how to use them to secure your API routes.

Implementing the Descope JWT Authorizer

To validate Descope JWTs, you'll need to fetch the public key from Descope's JWKS endpoint.

If you're using a custom domain, replace the Base URL below with your own.

Your JWKS URL follows this format:

https://<Descope Base URL>/<Your Descope Project ID>/.well-known/jwks.json
auth.py
from jwt import PyJWKClient
 
class TokenVerifier:
    def __init__(self):
        # In real apps, load this from a config or environment variable. This is just an example.
        self.jwks_url = "https://api.descope.com/__Project_ID__/.well-known/jwks.json"
        self.jwks_client = PyJWKClient(self.jwks_url)

To fetch the public keys, we'll define a helper method _get_signing_key() that wraps get_signing_key_from_jwt().

auth.py
def _get_signing_key(self, token: str):
    try:
        return self.jwks_client.get_signing_key_from_jwt(token).key
    except Exception as e:
        raise UnauthorizedException(f"Failed to fetch signing key: {str(e)}")

Setting Up Custom User-Agent for JWKS Fetching

In some cases you may need to configure a custom User-Agent header before making JWKS requests.

This is because the PyJWKClient internally uses Python's built-in urllib.request to fetch the JWKS. By default, urllib sends requests with a User-Agent like Python-urllib/3.x, which may be flagged or blocked by some CDNs or API gateways (as is the case with Descope's JWKS endpoint).

To avoid this, you can globally install a custom opener that adds a more typical User-Agent:

main.py
import urllib.request
 
# Set a custom User-Agent to avoid being blocked by security filters or rate limiters.
opener = urllib.request.build_opener()
opener.addheaders = [('User-agent', 'Mozilla/5.0 (DescopeFastAPISampleApp)')]
urllib.request.install_opener(opener)

This ensures JWKS requests are treated as legitimate traffic and not blocked as bot or scanner activity.

It's recommended to place this in your main.py or app startup script before any JWT validation occurs.

Validating the JWTs with the TokenVerifier

Once we've fetched the TokenVerifier class and the _get_signing_key() method, the next step is to decode and validate the JWT.

We'll implement a __call__ method inside our TokenVerifier class to handle this process.

Extracting the Token from Incoming Requests

FastAPI provides a built-in way to extract and parse access tokens using the HTTPBearer() dependency.

This reads the Bearer token from the HTTP authorization header from the incoming request and passes it into your function as an HTTPAuthorizationCredentials object.

First, you can define the custom exceptions that will be used for error handling:

exceptions.py
from fastapi import HTTPException, status
 
class UnauthenticatedException(HTTPException):
    def __init__(self):
        super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
 
class UnauthorizedException(HTTPException):
    def __init__(self, detail: str = "Not authorized"):
        super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

Next you can implement the __call__ method, which will be invoked by FastAPI whenever a protected route is accessed, to validate the incoming token.

You can find the Issuer URL under your default federated app settings here.

auth.py
from typing import Optional
 
import jwt  # From PyJWT
 
from fastapi import Depends, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
from app.exceptions import UnauthenticatedException, UnauthorizedException
 
async def __call__(
    self,
    # token injected by FastAPI Security, specified in the FastAPI route definition
    token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer())
):
    if token is None:
        raise UnauthenticatedException
 
    token = token.credentials
 
    key = self._get_signing_key(token)
    payload = self._decode_token(token, key)
 
    return payload
 
# helper which calls jwt.decode()
def _decode_token(self, token: str, key):
    try:
        return jwt.decode(
            token,
            key,
            algorithms=['RS256'], # modify if using different algorithm(s)
 
            # `issuer` is the expected value of the `iss` claim in the JWT.
            # It helps verify that the token was actually issued by a trusted entity
            # (e.g., your authentication backend, auth server, or token provider).
            # If the token's `iss` does not match the expected value, the token is rejected.
            issuer="https://api.descope.com/__Project_ID__/.well-known/openid-configuration", # Recommended: load from config, e.g., self.config.issuer
            
            # `audience` is the expected recipient of the token — usually your API or backend service.
            # This should match the `aud` claim in the token.
            # It ensures the token was intended to be used by your application, not another system.
            audience='my-api-audience' # Recommended: load from config
 
            # You may also add additional claims to validate here using the `options` argument,
            # or by manually inspecting the decoded payload after this step.
            # For example: enforce `sub`, `azp`, or custom claims depending on your app logic.
        )
    except Exception as e:
        raise UnauthorizedException(f"Token decoding failed: {str(e)}")

Enforcing Scopes

This section is optional. This is only necessary if you want to enforce scoped-based access control on your API routes.

In addition to validating your Descope access tokens in your FastAPI backend, you may also want to restrict access to specific API routes based on scopes/claims embedded in the JWT. You can read more scoping generally in our Inbound Apps docs page.

To do this, you can write a helper method _enforce_scopes(), that checks whether the token's scope claim contains all the required scopes for the API route.

If any are missing, the request is will be deined with a 403 Forbidden error, and optionally will include the missing scopes in the error message returned to the client.

auth.py
from typing import List
 
def _enforce_scopes(self, payload: dict, required_scopes: List[str]):
    scope_claim = payload.get("scope")
    if scope_claim is None:
        raise UnauthorizedException('Missing required claim: "scope"')
 
    # Scopes may be a space-separated string or a list
    scopes = scope_claim.split() if isinstance(scope_claim, str) else scope_claim
    missing = [scope for scope in required_scopes if scope not in scopes]
 
    if missing:
        raise UnauthorizedException(
            f'Missing required scopes: {", ".join(missing)}'
        )

Now, let's complete our __call__ function, which should now accept the SecurityScopes parameter:

The SecurityScopes parameter is automatically injected by FastAPI when you use the Security() dependency. It contains information about what scopes are required for the current route, allowing your authorizer to enforce scope-based access control dynamically.

auth.py
from typing import Optional
from fastapi import Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, SecurityScopes
 
async def __call__(
    self,
    security_scopes: SecurityScopes,
    token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer())
):
    if token is None:
        raise UnauthenticatedException
 
    token = token.credentials
 
    key = self._get_signing_key(token)
    payload = self._decode_token(token, key)
 
    # Enforce required scopes
    if security_scopes.scopes:
        self._enforce_scopes(payload, security_scopes.scopes)
 
    return payload

Protecting Routes Using the TokenVerifier

With the TokenVerifier fully implemented, you can use it to secure any route in your FastAPI application.

Making a Route Private

To protect a route with authentication (i.e., require a valid JWT), use the TokenVerifier as a dependency:

main.py
from app.auth import TokenVerifier
auth = TokenVerifier() 
 
@app.get("/api/private")
def private(auth_result: str = Security(auth)):
    # This API is now protected by our TokenVerifier object `auth`
    return auth_result

Adding Scope Validation to Private Routes

For more fine-grained access control via scopes, you can declare required scopes directly on the route.

This tells FastAPI to inject the decoded JWT token into the route, and enforce that it contains the read:messages and write:messages scopes:

main.py
from app.auth import TokenVerifier
auth = TokenVerifier() # if not already defined
 
@app.get("/api/private-scoped/write")
def private_scoped(auth_result: str = Security(auth, scopes=['read:messages', 'write:messages'])):
    """
    This is a protected route with scope-based access control.
 
    Access to this endpoint requires:
    - A valid access token (authentication), and
    - The presence of the `read:messages` and `write:messages` scope in the token.
    """
    return auth_result
Was this helpful?