n4nAI

Building an OAuth2 authorization server for LLM apps

Step-by-step tutorial for building OAuth2 authorization server for LLM apps with FastAPI and Authlib, issuing scoped JWTs for model access.

n4n Team3 min read584 words

Audio narration

Coming soon — every post will get a voice note here.

Most LLM apps bolt auth on as an afterthought, then struggle when they need to scope access to specific models or track usage per tenant. When building oauth2 authorization server llm integrations, you want a standards-compliant token issuer that emits JWTs with explicit scopes like llm:chat and llm:embed, so your resource servers can enforce them without a database round-trip.

Prerequisites

  • Python 3.11 or newer
  • pip install fastapi uvicorn authlib python-jose[cryptography]
  • A working knowledge of OAuth2 grant types (client credentials, authorization code)
  • curl and jq for testing endpoints
  • OpenSSL or python -c "import secrets; print(secrets.token_urlsafe(32))" to generate a signing key

This tutorial uses in-memory stores. Swap them for Redis or Postgres before production.

Scaffold the project

Create a single module aserver.py. We’ll stand up a FastAPI app and attach Authlib’s AuthorizationServer. Authlib handles the RFC 6749 mechanics; we supply client lookup and token persistence.

# aserver.py
from fastapi import FastAPI, Request
from starlette.responses import JSONResponse
from authlib.integrations.starlette_oauth2 import AuthorizationServer, ResourceProtector
from authlib.oauth2.rfc6749 import grants
from authlib.oauth2.rfc6749.models import Client
from jose import jwt
import time, uuid

app = FastAPI()
SIGNING_KEY = "replace-with-env-var"

Define the client and token stores

Authlib expects a query_client callable and a save_token callable. For a machine-to-machine LLM worker, the client credentials grant is enough. We register a single client with two scopes.

clients = {
    "llm-app-1": {
        "client_id": "llm-app-1",
        "client_secret": "secret-1",
        "redirect_uris": ["http://localhost:8000/callback"],
        "scope": "llm:chat llm:embed",
        "grant_types": ["client_credentials", "authorization_code"],
        "response_types": ["code"],
        "token_endpoint_auth_method": "client_secret_post",
    }
}

tokens = {}

def query_client(client_id):
    if client_id in clients:
        return Client(clients[client_id])
    return None

def save_token(token, request):
    tokens[token["access_token"]] = token

Issue JWT access tokens

Opaque tokens require introspection on every request. JWTs let the resource server validate scopes locally. We override the token generator to embed the granted scope and audience.

def generate_jwt_token(client, grant_type, user, scope):
    now = int(time.time())
    claims = {
        "iss": "https://auth.example.com",
        "sub": client.client_id,
        "aud": "llm-gateway",
        "scope": scope,
        "exp": now + 3600,
        "iat": now,
        "jti": str(uuid.uuid4()),
    }
    token = jwt.encode(claims, SIGNING_KEY, algorithm="HS256")
    return {
        "access_token": token,
        "token_type": "bearer",
        "expires_in": 3600,
        "scope": scope,
    }

Wire up the authorization server

Register the grants you need. Client credentials for backend jobs; authorization code for user-facing apps.

authorization_server = AuthorizationServer(
    app,
    query_client=query_client,
    save_token=save_token,
)

authorization_server.register_grant(grants.ClientCredentialsGrant)
authorization_server.register_grant(grants.AuthorizationCodeGrant)
authorization_server.token_generator = generate_jwt_token

@app.post("/oauth/token")
async def token_endpoint(request: Request):
    return await authorization_server.create_token_response(request)

Test client credentials flow

Run the server:

uvicorn aserver:app --port 8000

Request a token scoped to chat:

curl -X POST http://localhost:8000/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=llm-app-1 \
  -d client_secret=secret-1 \
  -d scope=llm:chat | jq

Expected output:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600,
  "scope": "llm:chat"
}

Decode the JWT and you’ll see the scope claim. That claim is the contract your LLM resource server enforces.

Protect a resource endpoint

Use ResourceProtector with a JWT validator. We check signature, audience, and scope locally.

from authlib.oauth2.rfc6749 import BearerTokenValidator

class JWTBearerValidator(BearerTokenValidator):
    def authenticate_token(self, token_string):
        try:
            return jwt.decode(token_string, SIGNING_KEY, algorithms=["HS256"], audience="llm-gateway")
        except Exception:
            return None

    def validate_token(self, token, scopes):
        if token["aud"] != "llm-gateway":
            raise ValueError("invalid audience")
        if not set(scopes).issubset(token["scope"].split()):
            raise ValueError("insufficient scope")

require_oauth = ResourceProtector()
require_oauth.register_token_validator(JWTBearerValidator())

@app.get("/v1/chat")
@require_oauth("llm:chat")
async def chat():
    return JSONResponse({"model": "gpt-4o-mini", "status": "authorized"})

Hit it without a token:

curl http://localhost:8000/v1/chat
# 401 {"error": "missing_authorization_header"}

Hit it with the token:

TOKEN=$(curl -X POST http://localhost:8000/oauth/token -d grant_type=client_credentials \
  -d client_id=llm-app-1 -d client_secret=secret-1 -d scope=llm:chat | jq -r .access_token)

curl http://localhost:8000/v1/chat -H "Authorization: Bearer $TOKEN"
# {"model": "gpt-4o-mini", "status": "authorized"}

Inspect the token claims

You can decode the token offline to debug scope mismatches:

from jose import jwt
decoded = jwt.decode(TOKEN, SIGNING_KEY, algorithms=["HS256"], audience="llm-gateway")
print(decoded["scope"])  # "llm:chat"

This avoids network round-trips when you scale to multiple model proxies behind a load balancer.

Forward the token to an LLM gateway

The point of building oauth2 authorization server llm infrastructure is to decouple auth from model routing. If you forward this bearer token to an OpenAI-compatible gateway such as n4n.ai, it can honor the scopes and apply per-token metering across 240+ models without you writing billing code. The gateway validates the JWT, checks scope, and forwards provider cache-control hints to the upstream model vendor.

Example call from Python:

import os, requests

resp = requests.post(
    "https://api.n4n.ai/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['TOKEN']}"},
    json={"model": "anthropic/claude-3.5-sonnet", "messages": [{"role": "user", "content": "hi"}]}
)

The gateway rejects the request with 403 insufficient_scope if llm:chat is absent.

Add authorization code with PKCE

Public SPAs can’t hold secrets. Register the auth code grant and serve the authorize endpoint:

@app.get("/oauth/authorize")
async def authorize(request: Request):
    return await authorization_server.create_authorization_response(request)

@app.post("/oauth/authorize")
async def authorize_confirm(request: Request):
    return await authorization_server.create_authorization_response(request)

Configure a client with token_endpoint_auth_method="none" and grant_types=["authorization_code"]. The PKCE flow is identical to standard OAuth2; Authlib validates the code_challenge automatically. The issued token still carries the same JWT structure, so your gateway enforcement doesn’t change.

Revocation and rotation

In-memory tokens can’t be revoked. For production, store the jti in a deny-list:

def save_token(token, request):
    tokens[token["access_token"]] = token
    # redis.set(token["jti"], "revoked", ex=token["expires_in"])

And check it in authenticate_token. Building oauth2 authorization server llm systems without revocation is a compliance risk if a key leaks. Rotate SIGNING_KEY via a JWKS endpoint so clients can fetch the new public key without a code change.

Production notes

  • Store SIGNING_KEY in a KMS; never hardcode it.
  • Persist clients and tokens in a database; implement revoke_token and introspect_token.
  • Use HTTPS and short exp (15m) with refresh tokens for user flows.
  • Scope naming matters: llm:chat, llm:embed, llm:fine_tune keep blast radius small.

When building oauth2 authorization server llm apps, treat scopes as capability tokens, not just labels. A token that can only embed should never reach a chat completion endpoint. You now have a runnable issuer that speaks RFC 6749 and emits validated JWTs your model gateway can trust.

Tagsoauth2authorization-servertutorialllm-app

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All oauth2 & bearer token auth for llm platforms posts →