Skip to main content

fastapi-oidc-guard

Strict OIDC bearer-token authentication for FastAPI resource servers.

The library validates externally issued JWT access tokens using OIDC discovery, cached JWKS, and PyJWT. Its default user-identity profile additionally requires and verifies UserInfo; an explicit JWT-only profile supports workload identities such as RFC 9068 client-credentials tokens. Authentication is opt-in through a typed FastAPI dependency. The library does not implement browser login, token issuance, sessions, opaque-token introspection, or OAuth grant processing.

Installation

pip install fastapi-oidc-guard

Python 3.12 or newer is required.

Usage

import contextlib
import dataclasses

from fastapi import FastAPI

from fastapi_oidc_guard import Authenticated, OidcConfig, VerifiedIdentity, authentication_context


@dataclasses.dataclass(frozen=True, slots=True)
class User:
    id: str
    name: str


async def resolve_user(identity: VerifiedIdentity) -> User | None:
    userinfo = identity.userinfo
    if userinfo.extra.get('role') != 'myrole':
        return None

    return User(id=identity.sub, name=userinfo.name or '')


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    async with authentication_context(
        app, OidcConfig(issuer='https://issuer.example', audience='project-id'), resolve_user
    ):
        yield


app = FastAPI(lifespan=lifespan)


@app.get('/public')
async def public_endpoint():
    return {'message': 'This could be anyone'}


@app.get('/authenticated')
async def authenticated_endpoint(user: Authenticated[User]):
    return {'message': f'Welcome back {user.name}!'}

authentication_context uses the default profile and eagerly downloads and validates discovery metadata and JWKS during application startup. By default, discovery must advertise jwks_uri, userinfo_endpoint, authorization_endpoint, and token_endpoint, and it must support the authorization-code flow. The resulting values seed the same TTL caches used while requests are served. Startup fails when an enabled provider capability is unavailable or invalid.

The user resolver must be async. It receives a fully verified VerifiedIdentity and returns the application's concrete user object. Returning None denies access with HTTP 403.

JWT-only and Workload Identities

Use OidcAuthenticator.jwt_only() for JWT access tokens that must not call UserInfo. The mapper receives a VerifiedToken containing only signature- and claim-verified JWT data:

import dataclasses

from fastapi import FastAPI

from fastapi_oidc_guard import OidcAuthenticator, OidcConfig, VerifiedToken


@dataclasses.dataclass(frozen=True, slots=True)
class Client:
    client_id: str


async def resolve_client(token: VerifiedToken) -> Client | None:
    client_id = token.claims.get('client_id')
    if not isinstance(client_id, str):
        return None
    return Client(client_id)


config = OidcConfig(
    issuer='https://issuer.example',
    audience='project-id',
    expected_token_type='at+jwt',
    openapi_authorization_code=False,
)
authenticator = OidcAuthenticator.jwt_only(config, resolve_client)
app = FastAPI(lifespan=authenticator.lifespan)

With openapi_authorization_code=False, JWT-only startup requires only issuer and jwks_uri from discovery. It does not require or call UserInfo and does not inspect authorization-code metadata. The JWT validator remains unchanged: iss, aud, sub, iat, and exp are still mandatory, and all signature, algorithm, key, lifetime, and claim checks still apply. The mapper is responsible for application authorization based on client_id, scopes, roles, or other verified claims.

This profile validates a JWT access token, not the OAuth grant that produced it. Opaque tokens and provider-specific client tokens without the required JWT claims remain unsupported. There is no fallback between profiles: the default profile always requires UserInfo, while JWT-only never calls it.

Manual and Composed Authentication

Applications that use middleware, custom dependencies, or direct token authentication can create one OidcAuthenticator. Its lifespan performs the same initialization as authentication_context, and Authenticated[...] automatically uses that instance:

import typing as t

from fastapi import Depends, FastAPI, Request

from fastapi_oidc_guard import Authenticated, OidcAuthenticator, OidcConfig


authenticator = OidcAuthenticator(
    OidcConfig(issuer='https://issuer.example', audience='project-id'), resolve_user
)
app = FastAPI(lifespan=authenticator.lifespan)


async def current_user(request: Request) -> User:
    return await authenticator.authenticate_connection(request)


@app.get('/composed')
async def composed_endpoint(
    request: Request,
    injected: Authenticated[User],
    manual: t.Annotated[User, Depends(current_user)],
):
    repeated = await authenticator.authenticate_connection(request)
    assert injected is manual is repeated
    return {'message': f'Welcome back {injected.name}!'}

authenticate_connection() accepts an HTTP Request or a WebSocket. It extracts the bearer token and stores an in-flight task in the shared ASGI connection state. Middleware, custom dependencies, and Authenticated[...] therefore share the complete authentication operation. Whichever runs first performs JWT validation, the configured identity profile, and identity mapping; later calls return the exact same mapped user object. Failed operations are not retained.

Middleware that does not retain the authenticator can use get_authentication_result(). It discovers the active authenticator through the connection, performs or joins the same authentication operation, and stores a successful mapped user in the same private connection state used by Authenticated[...]:

from fastapi_oidc_guard import AuthenticationFailure, get_authentication_result


@app.middleware('http')
async def log_authenticated_user(request: Request, call_next):
    result = await get_authentication_result(request, User)
    if isinstance(result, AuthenticationFailure):
        logger.info('authentication failed status=%s reason=%s', result.status_code, result.reason)
    else:
        logger.info('request user_id=%s', result.id)
    return await call_next(request)

Expected 401 and 403 outcomes are returned as AuthenticationFailure values instead of being raised. The value contains status_code, the configured client-facing detail, an optional www_authenticate challenge, and the detailed InvalidTokenReason when a token was invalid. Provider, configuration, cancellation, and unexpected application failures still propagate. If the middleware continues downstream after success, Authenticated[...] receives the exact same user without repeating validation, UserInfo, or mapping. Expected failures retain the normal non-cached retry behavior. The requested user type must be a concrete runtime-checkable class, and AuthenticationFailure is reserved for failure values rather than application mapped users.

For a raw JWT that is not associated with a request or WebSocket, use:

user = await authenticator.authenticate_token(token)

Raw-token calls use the same validator, configured identity profile, and mapper, but do not participate in connection-scoped result caching. Discovery, JWKS, and enabled UserInfo verification retain their configured caches. Both public authentication methods must be called while authenticator.lifespan is active.

The configured bearer-input limit applies to both methods. For direct calls it is checked before PyJWT parses the token.

authentication_context() remains available as a convenience for composed application lifespans and now yields the same public service:

async with authentication_context(app, config, resolve_user) as authenticator:
    yield

Public authentication failures derive from OidcGuardError. Applications may handle CredentialsMissingError, InvalidTokenError, InsufficientPermissionsError, IdentityProviderUnavailableError, InvalidProviderResponseError, and ConfigurationError. These methods never translate failures into FastAPI HTTPException; that translation remains the responsibility of Authenticated[...].

Error Handling

Authentication Responses

Authenticated[...] returns specific, constant authentication details by default. Invalid-token responses include WWW-Authenticate: Bearer error="invalid_token" with the same fixed detail in error_description; missing credentials use WWW-Authenticate: Bearer. No response includes the token, key ID, claim values, configured policy values, or provider URLs.

Public reason Status HTTP detail
Missing credentials (separate CredentialsMissingError) 401 Bearer credentials are required.
MALFORMED_AUTHORIZATION 401 Authorization must contain one Bearer credential.
CREDENTIALS_TOO_LARGE 401 Bearer credentials exceed the supported size.
MALFORMED_TOKEN 401 The bearer token is not a valid compact JWT.
UNSUPPORTED_ALGORITHM 401 The token signing algorithm is not accepted.
SIGNING_KEY_NOT_FOUND 401 No signing key is available for this token.
SIGNING_KEY_INCOMPATIBLE 401 The signing key is incompatible with this token.
INVALID_SIGNATURE 401 The token signature is invalid.
INVALID_ISSUER 401 The token issuer is not accepted.
INVALID_AUDIENCE 401 The token audience is not accepted.
EXPIRED 401 The token has expired.
NOT_ACTIVE 401 The token is not active yet.
INVALID_TYPE 401 The token type is not accepted.
INVALID_CLAIMS 401 The token contains invalid or missing claims.
USERINFO_REJECTED 401 The identity provider rejected the token for UserInfo.
USERINFO_SUBJECT_MISMATCH 401 The token and UserInfo identify different subjects.
Mapper denied identity (separate InsufficientPermissionsError) 403 The authenticated identity is not permitted to use this application.

Generic Responses

Set generic_authentication_errors=True to collapse all 401 details to Missing or invalid authorization and mapper denials to Not permitted. RFC challenges remain distinct, but generic invalid-token challenges omit error_description:

config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', generic_authentication_errors=True
)

This setting affects client-facing details generated by Authenticated[...] and AuthenticationFailure. The failure value still retains the detailed InvalidTokenReason for server-side policy and logging.

Provider and Integration Responses

Condition Status HTTP detail
Malformed provider response 502 Invalid identity provider response
Provider timeout, rate limit, or 5xx 503 Identity provider unavailable
Missing authenticator lifespan 500 OIDC authentication is not initialized
Mapped-user type mismatch 500 Authentication user type mismatch

All response bodies use FastAPI's stable {'detail': '...'} format without dynamic PyJWT, provider, token, claim, key, or configured policy values.

Manual Authentication

Direct authenticate_token() and authenticate_connection() calls raise public library exceptions instead of HTTPException. InvalidTokenError exposes a stable InvalidTokenReason in error.reason, allowing custom integrations to choose their own status codes, envelopes, and disclosure policy. get_authentication_result() instead converts only expected 401/403 outcomes to AuthenticationFailure values. Signature validation takes precedence over claim diagnostics.

Verified Tokens and Identities

VerifiedToken retains information needed by downstream authorization policies:

  • sub, issued_at, expires_at, issuer, and audiences
  • the verified signing kid and algorithm
  • all verified JWT claims as a recursively immutable mapping

VerifiedIdentity extends VerifiedToken with typed, non-optional userinfo. Known UserInfo fields are available as attributes. Additional non-null JSON claims are retained in userinfo.extra.

The mapper type reflects the selected profile, so JWT-only code does not receive an optional UserInfo value and default-profile code can rely on UserInfo being present.

Configuration

from datetime import timedelta

from fastapi_oidc_guard import OidcConfig

config = OidcConfig(
    issuer='https://issuer.example',
    audience='project-id',
    allowed_algorithms=(
        'RS256',
        'RS384',
        'RS512',
        'PS256',
        'PS384',
        'PS512',
        'ES256',
        'ES384',
        'ES512',
        'EdDSA',
    ),
    discovery_cache_ttl=timedelta(minutes=15),
    jwks_cache_ttl=timedelta(minutes=15),
    userinfo_cache_ttl=timedelta(minutes=5),
    userinfo_cache_max_entries=1024,
    leeway=timedelta(seconds=30),
    max_token_lifetime=None,
    max_bearer_length=16 * 1024,
    generic_authentication_errors=False,
    expected_token_type=None,
    openapi_authorization_code=True,
    openapi_scopes=None,
    swagger_ui_client_id=None,
    http_timeout=timedelta(seconds=5),
    allow_insecure_http=False,
)

Supported algorithms are RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, and EdDSA. All are enabled by default. Only configured algorithms are accepted; provider metadata cannot expand this allowlist. Applications can narrow it to the exact algorithm or algorithms used by their provider.

Set expected_token_type='at+jwt' when the provider emits RFC 9068 access tokens. It remains optional because many providers omit typ or use a provider-specific value.

HTTP is rejected by default. allow_insecure_http=True exists for explicit local-development setups and should not be enabled in production.

Bearer Input Limit

max_bearer_length defaults to 16 KiB and is inclusive. For HTTP requests and WebSockets, the limit applies to the complete raw Authorization field value in bytes, including the Bearer scheme and any whitespace. The raw ASGI headers are checked before the value is decoded, split, or passed to FastAPI's authentication machinery. Multiple Authorization fields are rejected.

For direct authenticate_token() calls, the library measures the token as a canonical Bearer <token> field value. Compact JWTs are ASCII, so their character and byte lengths are identical and no encoded copy is needed. After the size check, non-ASCII input is rejected as malformed. A canonical HTTP header and direct authentication therefore have the same inclusive boundary. HTTP-specific whitespace still counts toward the actual raw field value.

Providers with larger legitimate credentials can raise the limit explicitly:

config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', max_bearer_length=32 * 1024
)

Oversized credentials raise InvalidTokenError with InvalidTokenReason.CREDENTIALS_TOO_LARGE. They do not trigger token parsing, UserInfo, or identity mapping. Discovery and JWKS are still fetched eagerly during application startup.

This application-level check cannot prevent the ASGI server or a reverse proxy from initially receiving and allocating an oversized field. Configure complementary per-header, total-header, and header-count limits at those layers.

OpenAPI

Routes using Authenticated[...] reference an OidcBearer OAuth2 authorization-code security scheme. Its authorization and token URLs come from the cached OIDC discovery response. Scopes come from openapi_scopes when configured; otherwise they come from discovery, defaulting to openid when scopes_supported is absent. Swagger UI initially selects only openid rather than every scope advertised by the provider. Generating OpenAPI does not make another provider request.

This behavior remains enabled by default. Set openapi_authorization_code=False when the resource server does not use interactive authorization-code documentation:

config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', openapi_authorization_code=False
)

FastAPI then advertises OidcBearer as a standard HTTP bearer scheme, and Swagger UI still allows a token to be entered manually. The provider's authorization_endpoint, token_endpoint, response_types_supported, grant_types_supported, and scopes_supported values are not required or validated because they are not used. openapi_scopes and swagger_ui_client_id cannot be set while authorization-code integration is disabled.

Set openapi_scopes to replace the discovered scope list:

config = OidcConfig(
    issuer='https://issuer.example',
    audience='project-id',
    openapi_scopes=('openid', 'email', 'profile', 'urn:example:custom'),
)

The override must contain unique OAuth2 scope names and include openid. It affects the generated OpenAPI authorization flow and becomes Swagger UI's initially selected scope list. It does not validate a token's scope claim or grant permissions. Enforce application authorization in the async identity mapper.

Set swagger_ui_client_id for a public documentation client:

config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', swagger_ui_client_id='swagger-ui'
)

The configured client ID replaces any clientId already present in FastAPI's swagger_ui_init_oauth. Other application-owned Swagger settings are preserved, and PKCE is enabled unless the application explicitly configures usePkceWithAuthorizationCodeGrant. Client secrets are intentionally unsupported because Swagger UI is a browser-based public client. An application-provided swagger_ui_init_oauth['scopes'] also takes precedence over the default or openapi_scopes selection.

Validation Policy

The hardened policy is not configurable down to unsafe compatibility behavior:

  • iss, aud, sub, iat, and exp are required.
  • iat, exp, and optional nbf must be JSON integers; booleans, floats, and strings fail.
  • Discovery's issuer must exactly match the configured issuer.
  • JWT header alg, the local allowlist, and an explicit JWK alg must agree.
  • JWK entries with use other than sig are ignored; otherwise acceptable entries without kid are also ignored.
  • Signing candidates with private or symmetric material, weak RSA keys, malformed key data, or non-verification key_ops are rejected before selection.
  • Algorithm-incompatible candidates do not count as usable; startup and refresh require at least one signing key permitted by local policy.
  • Duplicate kid values among retained signing entries are rejected.
  • An unknown kid is rejected without causing an outbound request.
  • Bearer inputs exceeding max_bearer_length are rejected before JWT header parsing.
  • Multiple Authorization fields are rejected as invalid credentials.
  • In the default profile, UserInfo sub must exactly match the verified token sub.
  • Raw bearer tokens and Authorization headers are never included in library errors.

UserInfo is cached by a SHA-256 token fingerprint, not by subject. Its configurable cache defaults to a five-minute TTL and 1,024 entries. The TTL is capped by token expiration, and concurrent fetches are deduplicated.

Readiness and Provider Refresh

OidcAuthenticator.status() returns an immutable operational snapshot without performing network I/O:

status = authenticator.status()

if status.ready:
    print(f'provider generation {status.generation} is ready')

The snapshot reports whether the lifespan is active, whether a refresh is running, discovery and JWKS freshness, the number of usable configured signing keys, generation, and sanitized UTC refresh timestamps. expires_in values are derived from monotonic cache deadlines and decrease between snapshots. Status never contains provider URLs, key identifiers, JWK values, tokens, UserInfo, cached fingerprints, headers, or exception messages.

ready requires an active lifespan, fresh discovery and JWKS material, and at least one usable signing key. The default profile also validates that discovery contains an acceptable UserInfo URL, but readiness does not call UserInfo or assert that it is currently reachable. Authorization-code OpenAPI metadata is validated at startup but is not part of runtime authentication readiness.

For passive telemetry, status() intentionally becomes not-ready after either provider cache expires and does not initiate a refresh. In a traffic-gated deployment, use ensure_provider_ready() in the readiness probe so an idle replica can restore readiness without waiting for an authentication request:

from fastapi import Response


@app.get('/ready')
async def ready():
    status = await authenticator.ensure_provider_ready()
    return Response(status_code=200 if status.ready else 503)

The async check returns immediately without network I/O while discovery and JWKS are fresh. When material has expired, it uses the normal conditional refresh path: JWKS is refreshed alone when discovery remains fresh, concurrent checks share one operation, and recent failures observe the normal retry cooldown. Expected provider failures are represented by a sanitized AuthenticationStatus with ready=False rather than being exposed by the readiness endpoint. The check does not call UserInfo. Cancellation and unexpected local errors still propagate; cancelling one caller does not cancel a refresh shared with other callers. Liveness probes should remain independent of the identity provider.

Use refresh_provider() from a separately authenticated and rate-limited administrative operation when an immediate provider refresh is required:

@app.post('/operations/oidc/refresh')
async def refresh_oidc():
    status = await authenticator.refresh_provider()
    return {'generation': status.generation}

The refresh fetches candidate discovery first, follows its candidate jwks_uri, validates all profile requirements and signing-key usability, and then publishes one provider generation. Concurrent refresh calls share the same operation. A failed refresh raises a sanitized provider exception and leaves existing material unchanged; that material remains ready only while its TTLs remain fresh. Refresh does not call UserInfo, clear UserInfo results, or modify the application's startup OpenAPI and Swagger configuration.

Status is available outside the lifespan with active=False and ready=False. Calling ensure_provider_ready() or refresh_provider() outside the active lifespan raises ConfigurationError. The library does not mount operational routes or authorize refresh callers on the application's behalf.

Key Rotation

JWKS is fetched at application startup, on normal TTL refresh, or through an explicit refresh_provider() call. Token-controlled values, including an unknown kid, never force a refresh or bypass the configured TTL.

For rotation without rejected tokens, the identity provider must:

  1. Publish the next public key at least one complete JWKS TTL before using it to sign tokens.
  2. Keep an old public key published until all tokens signed by it have expired, including leeway.
  3. Use refresh_provider() when an emergency rotation also changes jwks_uri.

Emergency or unannounced rotations can cause authentication failures until the cache expires or the application explicitly refreshes the provider. This is intentional: provider key management and application-controlled operations, rather than attacker-controlled token headers, determine when network refreshes occur.

Identity Profiles

The default OidcAuthenticator(...) and authentication_context(...) profile accepts user access tokens that the discovered userinfo_endpoint accepts. Appropriate provider scopes, commonly openid, profile, and email, must be granted when the corresponding claims are required.

UserInfo is fetched successfully and its sub is matched against the JWT before the application mapper runs. Therefore VerifiedIdentity.userinfo is never None. UserInfo failure rejects the request instead of producing a partial identity.

OidcAuthenticator.jwt_only(...) accepts locally verifiable JWT access tokens without UserInfo and passes a VerifiedToken to its mapper. Selecting JWT-only is explicit and fixed for the authenticator's lifetime; UserInfo failure can never cause the default profile to downgrade to it.

Configuration Parsing

OidcConfig rejects undeclared fields. This makes configuration typos fail during startup instead of silently retaining a default authentication or cache policy. Validation errors identify the unknown field and retain its supplied value for debugging.

The strict policy applies only to the OidcConfig boundary. A containing application model keeps its own extra-field policy:

from pydantic import BaseModel


class ApplicationConfig(BaseModel):
    oidc: OidcConfig
    storage_url: str

Applications that previously passed a complete application mapping directly to OidcConfig must select the OIDC section:

oidc_config = OidcConfig.model_validate(application_config['oidc'])

Subclasses may add declared application-specific fields and inherit strict unknown-field handling. An application that temporarily requires the previous behavior can opt into it explicitly as a migration escape hatch:

class LenientOidcConfig(OidcConfig, extra='ignore', frozen=True):
    pass

Typing

For Pyright, Authenticated[User] is the same static type as User. At runtime it expands to a normal Annotated[User, Depends(...)] dependency and validates the returned value with isinstance.

The type argument must therefore be a concrete runtime-checkable class. Unions, parameterized generics, Any, TypedDict, and non-runtime-checkable protocols are not supported. get_authentication_result() applies the same restriction to its user_type argument.

Development

uv sync
uv run pyright
uv run ruff format --check .
uv run ruff check .
uv run pytest
uv run behave
uv build

Pyright runs in strict mode using its Node.js extra. Ruff checks all rules except missing return annotations, docstrings, security, type-checking import rules, and formatter-conflicting COM812/ISC001; formatting uses spaces, LF line endings, a 100-character line length, single quotes, and no magic trailing comma. Pytest covers technical components and API-level behavior, while Behave covers business-level authentication outcomes. GitLab CI runs the same quality checks and builds the package from .gitlab-ci.yml.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_oidc_guard-0.4.0.tar.gz (39.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_oidc_guard-0.4.0-py3-none-any.whl (37.8 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_oidc_guard-0.4.0.tar.gz.

File metadata

  • Download URL: fastapi_oidc_guard-0.4.0.tar.gz
  • Upload date:
  • Size: 39.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for fastapi_oidc_guard-0.4.0.tar.gz
Algorithm Hash digest
SHA256 b4b2a2c42dd650c21de49b5bc756068198dc2bd122427049245904b2c2337fdc
MD5 f5729cf10be10c4edb2945485ff57b43
BLAKE2b-256 3b743ceccba651818a91d627c13436afbfb57667a18dd756da23ad04f92ca699

See more details on using hashes here.

File details

Details for the file fastapi_oidc_guard-0.4.0-py3-none-any.whl.

File metadata

File hashes

Hashes for fastapi_oidc_guard-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fb638e42fad53d5d951580c99dbb291a796badc383d14241e2c54ab0912261ce
MD5 0615dbaf7f755d0653d8e5f6ad81b865
BLAKE2b-256 864d1c0f647b072a9faa5d33decc1577616c32e2342d7c47599ffbdd76e70eb3

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 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