Skip to main content

keycardai-starlette

Starlette/FastAPI integration for Keycard. Plugs into Starlette's standard authentication framework: an AuthenticationBackend populates request.user and request.auth, the @requires decorator gates routes, and @auth.grant(resource) performs delegated OAuth 2.0 token exchange.

Installation

pip install keycardai-starlette

Quick Start

from fastapi import FastAPI, Request
from keycardai.starlette import AuthProvider, KeycardUser, requires
from keycardai.oauth.server import AccessContext, ClientSecret

auth = AuthProvider(
    zone_id="your-zone-id",
    application_credential=ClientSecret(("client_id", "client_secret")),
    # Resource indicator (RFC 8707) Keycard mints tokens for. The verifier
    # rejects tokens whose "aud" claim does not include this value.
    audience="https://your-api.example.com",
)

app = FastAPI()
auth.install(app)  # AuthenticationMiddleware + /.well-known/* routes

@app.get("/health")
async def health():
    return {"ok": True}                    # public, no decorator

@app.get("/api/me")
@requires("authenticated")                 # standard Starlette gating
async def me(request: Request):
    user: KeycardUser = request.user
    return {"client_id": user.client_id, "scopes": list(request.auth.scopes)}

@app.get("/api/data")
@requires("authenticated")
@auth.grant("https://api.example.com")     # delegated token exchange (RFC 8693)
async def get_data(request: Request, access: AccessContext):
    token = access.access("https://api.example.com").access_token

Leaving audience unset disables the audience check: the verifier accepts any token minted by the zone regardless of its aud claim.

Configuration from the environment

AuthProvider reads its zone and application credential from the environment when they are not passed explicitly.

Zone: set KEYCARD_ZONE_URL to the full zone URL. KEYCARD_ZONE_ID and KEYCARD_BASE_URL still work but emit a DeprecationWarning; migrate as follows:

Deprecated Canonical replacement
KEYCARD_ZONE_ID="abc1234" KEYCARD_ZONE_URL="https://abc1234.keycard.cloud"
KEYCARD_ZONE_ID="abc1234" + KEYCARD_BASE_URL="https://custom.example.com" KEYCARD_ZONE_URL="https://abc1234.custom.example.com"

Application credential: discovery is delegated to keycardai.oauth.server.discover_credential. KEYCARD_APPLICATION_CREDENTIAL_TYPE selects client_secret, workload_identity or web_identity (eks_workload_identity is accepted as a legacy alias). Without a selector, exactly one source may be configured: KEYCARD_CLIENT_ID + KEYCARD_CLIENT_SECRET, an injected workload token file (AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, AWS_WEB_IDENTITY_TOKEN_FILE, AZURE_FEDERATED_TOKEN_FILE or KEYCARD_EKS_WORKLOAD_IDENTITY_TOKEN_FILE), or KEYCARD_WEB_IDENTITY_KEY_STORAGE_DIR. More than one source with no selector fails at startup with an ambiguity error naming the selector as the remedy.

How it integrates with Starlette

AuthProvider.install(app) does two things:

  1. Adds starlette.middleware.authentication.AuthenticationMiddleware wired to a KeycardAuthBackend so every request gets a populated request.user and request.auth.
  2. Mounts the OAuth discovery endpoints under /.well-known/.

Routes you do not decorate stay public: the backend returns None (anonymous user) when no Authorization header is present, exactly like starlette.authentication.UnauthenticatedUser. Routes that need a verified caller use the @requires(...) decorator.

Decorators

@requires(scopes)

keycardai.starlette.requires is a drop-in for starlette.authentication.requires with one difference: anonymous requests get an RFC 6750 401 response with a WWW-Authenticate: Bearer ... resource_metadata="..." header (RFC 9728) instead of stock HTTPException(403). Scope checks behave the same.

@requires("authenticated")              # any verified caller
@requires(["authenticated", "admin"])   # additional scope check

AuthProvider.requires is exposed as a static-method alias if you prefer accessing the decorator via the provider instance:

@auth.requires("authenticated")

@auth.grant(resource)

Performs OAuth 2.0 delegated token exchange (RFC 8693) for one or more downstream resources and injects an AccessContext parameter into the endpoint. Mirrors the @grant() decorator from keycardai-mcp so the decorator name is consistent across packages.

@app.get("/api/calendar")
@requires("authenticated")
@auth.grant("https://graph.microsoft.com")
async def calendar(request: Request, access: AccessContext):
    token = access.access("https://graph.microsoft.com").access_token

Errors from the exchange are stored per-resource on the AccessContext rather than raised: call access.has_errors() / access.get_errors() to decide how to respond. The AccessContext parameter is hidden from FastAPI introspection via __signature__ rewriting, so it never appears in the generated OpenAPI schema.

Other entry points

protected_router()

Mount any ASGI app behind Keycard authentication and the /.well-known/* metadata routes in one call. Useful when every route under some prefix needs the same protection (for example an MCP transport, an internal admin app).

from keycardai.starlette import protected_router
from starlette.applications import Starlette

inner = build_my_api()  # any ASGI app

app = Starlette(routes=protected_router(
    issuer=auth.issuer,
    app=inner,
    verifier=auth.get_token_verifier(),
))

Opaque sub-apps: require_authentication=True

@requires(...) only gates routes you decorate. A mounted sub-app that handles its own routing (an MCP JSONRPC dispatcher, a gRPC handler, any non-Starlette ASGI app) bypasses route decorators, so anonymous requests would fall through to it. Pass require_authentication=True to make the backend itself the gate: requests without an Authorization header get an RFC 6750 401 challenge instead of reaching the sub-app anonymously.

app = Starlette(routes=protected_router(
    issuer=auth.issuer,
    app=inner,
    verifier=auth.get_token_verifier(),
    require_authentication=True,  # every request to `inner` needs a token
))

The same flag exists on KeycardAuthBackend(verifier, require_authentication=True) when you register the middleware yourself. OAuth metadata paths under /.well-known/ stay public either way (RFC 9728 §2, RFC 8414 §3).

AuthenticationMiddleware directly

For full control over middleware ordering, register the standard Starlette middleware yourself:

from starlette.middleware.authentication import AuthenticationMiddleware
from keycardai.starlette import KeycardAuthBackend, keycard_on_error

app.add_middleware(
    AuthenticationMiddleware,
    backend=KeycardAuthBackend(auth.get_token_verifier()),
    on_error=keycard_on_error,
)

What install() adds

  • AuthenticationMiddleware with KeycardAuthBackend
  • /.well-known/oauth-protected-resource (RFC 9728)
  • /.well-known/oauth-authorization-server (RFC 8414)
  • /.well-known/jwks.json (only when WebIdentity is configured)

The middleware never gates access on its own; it just populates request.user / request.auth. Routes you do not decorate stay public.

Release files for keycardai-starlette 0.14.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for keycardai-starlette 0.14.1
File Size Uploaded
keycardai_starlette-0.14.1.tar.gz 72.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for keycardai-starlette 0.14.1
File Interpreter ABI Platform
keycardai_starlette-0.14.1-py3-none-any.whl Python 3 none any Details

Total release size: 98.9 kB

Release files / keycardai_starlette-0.14.1.tar.gz

Download URL keycardai_starlette-0.14.1.tar.gz
Size 72.9 kB
Tags Source
SHA-256 checksum
How to use checksums
917cd83e03e3fd51217e3c8875cd61ac9e06a4b6b077e34ee46937461e98f6c6
BLAKE2b-256 checksum
How to use checksums
b28d08107682b34551cc7856b8f0cf6a639e045a1d2b00a610d5f7aaf7a0e3d8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / keycardai_starlette-0.14.1-py3-none-any.whl

Download URL keycardai_starlette-0.14.1-py3-none-any.whl
Size 26.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7432acc0abb0910b6210e5eb6fdbb7a238752a6d571c4dcd217cf1bb977dcf60
BLAKE2b-256 checksum
How to use checksums
82fe3e3405f3f8b245be3cf0bb91689f839b039bf0d11ef796a6b0ae532a863e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.14.2

2 release files

This release

0.14.1 This release

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page