mrkeyoor.com_
Thu 06 Aug 02:03 UTC
PyPISecurityupdated 06 Aug 2026

authlib

Authlib is the Python toolkit for both sides of OAuth: consuming other people's providers and running your own. On the client side it gives you framework integrations (Flask, Django, Starlette, FastAPI) plus OAuth1Session, OAuth2Session for requests and AsyncOAuth2Client for HTTPX, so 'log in with Google' is a register() call and two routes. On the server side it gives you the grant classes, token endpoints, introspection, revocation, PKCE, device code, dynamic client registration and an OpenID Connect layer to build an authorization server that passes spec review. It also historically shipped its own JOSE implementation for JWS, JWE, JWK and JWT, though that module is now deprecated in favor of the separate joserfc package by the same author.

Verdict

The most complete OAuth and OpenID Connect implementation in Python, and the right choice when you are building a provider or need clients across sync and async frameworks. Treat it as security-critical dependency: pin it, watch its advisories, and upgrade promptly, because 2025 and 2026 brought several serious ones.

API stability4/5The 1.x client and grant APIs have held since 2022 and framework integrations keep the same shape across Flask, Django, and Starlette; the deprecation of the authlib.jose module is a real migration for anyone who used it for plain JWT work
Docs4/5docs.authlib.org covers every RFC it implements with runnable examples for each framework and separate client, authorization server, and resource server tracks; navigating between those tracks is confusing at first and web tutorials still show deprecated imports
Maintenance4/5Pushed July 2026 with 1.7.2 released May 2026 and 115 open issues (143 counting PRs); security reports get fast patch releases, but the project is essentially one maintainer with sponsorship and a commercial license option
Ecosystem5/5Around 36M weekly downloads, 5.4k stars, and integrations shipped in the box for Flask, Django, Starlette, FastAPI, requests, and HTTPX; it is the default OAuth dependency in a large share of Python web stacks

Use it if

  • You are adding social or enterprise login to Flask, Django, Starlette or FastAPI and want one registry object that handles the redirect, state, PKCE, and id_token parsing
  • You are building an OAuth 2.0 or OpenID Connect provider and want RFC-numbered grant classes rather than gluing token endpoints together yourself
  • You need a client for machine-to-machine access with client_credentials, private_key_jwt, or service-account assertions, with automatic refresh through an update_token callback
  • You need both sync (requests) and async (HTTPX) OAuth clients that share one API and one mental model
Skip it if

Setup reality

pip install authlib pulls in cryptography and joserfc, both of which ship wheels for common platforms, so there is normally no compiler involved. The work is configuration, not installation. Framework integrations expect a session: Starlette and FastAPI need SessionMiddleware added before any login route works, or state validation fails with a confusing mismatch error. OpenID Connect logins want server_metadata_url plus 'openid' in client_kwargs scope, and only then does authorize_access_token() populate token['userinfo']. Provider quirks (non-standard token responses, missing metadata documents) still need compliance fixes. The docs are split across client, authorization server, and resource server sections, and older tutorials on the web use the deprecated authlib.jose imports, so copying from search results is how people end up on the wrong API.

Patterns

Add GitHub login to Flaskflask-oauth-login

from authlib.integrations.flask_client import OAuth
from flask import url_for, redirect

oauth = OAuth(app)
oauth.register(
    name="github",
    client_id="...",
    client_secret="...",
    access_token_url="https://github.com/login/oauth/access_token",
    authorize_url="https://github.com/login/oauth/authorize",
    api_base_url="https://api.github.com/",
    client_kwargs={"scope": "user:email"},
)

@app.route("/login")
def login():
    return oauth.github.authorize_redirect(url_for("authorize", _external=True))

@app.route("/authorize")
def authorize():
    token = oauth.github.authorize_access_token()
    profile = oauth.github.get("user").json()
    return redirect("/")

Flask routes do not take a request argument here; Authlib reads it from the app context. Client id and secret can also come from app.config keys named GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.

Log in with OpenID Connect and read the user claimsoidc-login-userinfo

oauth.register(
    "google",
    client_id="...",
    client_secret="...",
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid profile email"},
)

@app.route("/auth")
def auth():
    token = oauth.google.authorize_access_token()
    userinfo = token["userinfo"]  # id_token already parsed and verified
    session["user"] = {"sub": userinfo["sub"], "email": userinfo.get("email")}
    return redirect("/")

token['userinfo'] only appears when the scope includes openid and the provider returns an id_token. Without server_metadata_url you must supply jwks_uri and issuer yourself for verification to work.

Wire the async client into Starlette or FastAPIstarlette-async-login

from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth

app.add_middleware(SessionMiddleware, secret_key="change-me")
oauth = OAuth()
oauth.register("google", server_metadata_url=META_URL,
               client_id=CID, client_secret=CS,
               client_kwargs={"scope": "openid email"})

@app.get("/login")
async def login(request):
    return await oauth.google.authorize_redirect(request, request.url_for("auth"))

@app.get("/auth")
async def auth(request):
    token = await oauth.google.authorize_access_token(request)
    return {"email": token["userinfo"]["email"]}

SessionMiddleware is required: Authlib stores the state and nonce in the session, and without it the callback fails with a state mismatch. The Starlette registry uses HTTPX under the hood, so every call is awaited.

Turn on PKCE for the authorization code flowenable-pkce

oauth.register(
    "provider",
    client_id="...",
    client_secret="...",
    server_metadata_url=META_URL,
    client_kwargs={
        "scope": "openid profile",
        "code_challenge_method": "S256",
    },
)

Authlib generates and stores the code_verifier for you once code_challenge_method is set. Public clients should also set token_endpoint_auth_method to 'none' instead of sending a secret.

Run the code flow with requests, outside a frameworkrequests-authorization-code

from authlib.integrations.requests_client import OAuth2Session

client = OAuth2Session(
    client_id, client_secret,
    scope="read:data", redirect_uri="https://app.example.com/callback",
)
uri, state = client.create_authorization_url("https://provider.example.com/authorize")
# send the user to `uri`, keep `state`, then on the callback:
token = client.fetch_token(
    "https://provider.example.com/token",
    authorization_response=full_callback_url,
)
resp = client.get("https://provider.example.com/api/me")

You are responsible for storing and comparing state yourself here; only the framework integrations do that for you. Close the session (or use a with block) when you are done.

Get a machine-to-machine tokenclient-credentials

from authlib.integrations.requests_client import OAuth2Session

with OAuth2Session(client_id, client_secret, scope="jobs:write") as client:
    token = client.fetch_token(
        "https://provider.example.com/oauth/token",
        grant_type="client_credentials",
    )
    client.post("https://api.example.com/jobs", json={"name": "nightly"})

Pass token_endpoint_auth_method='client_secret_post' when the provider rejects HTTP basic auth; the default is client_secret_basic.

Refresh expired tokens automaticallyauto-refresh-token

def save_token(token, refresh_token=None, access_token=None):
    # look the row up by the old refresh_token or access_token, then persist
    store.update(refresh_token or access_token, token)

client = OAuth2Session(
    client_id, client_secret,
    token=stored_token,
    token_endpoint="https://provider.example.com/oauth/token",
    update_token=save_token,
)
client.get("https://api.example.com/me")  # refreshes in place if expired

Without update_token the refreshed token exists only in memory and the next process start uses the stale one. The token dict needs expires_at for Authlib to know it expired.

Authenticate to the token endpoint with a signed assertionprivate-key-jwt

from authlib.integrations.requests_client import OAuth2Session
from authlib.oauth2.rfc7523 import PrivateKeyJWT

with open("private-key.pem", "rb") as f:
    private_key = f.read()

token_endpoint = "https://provider.example.com/oauth/token"
session = OAuth2Session(
    "your-client-id", private_key,
    token_endpoint_auth_method=PrivateKeyJWT(token_endpoint),
)
session.fetch_token(token_endpoint)

The private key goes in the client_secret position, which reads oddly but is what the API expects. ClientSecretJWT works the same way for shared-secret providers.

Validate bearer tokens on your own APIprotect-api-endpoint

from flask import jsonify
from authlib.integrations.flask_oauth2 import ResourceProtector, current_token
from authlib.oauth2.rfc6750 import BearerTokenValidator

class MyBearerTokenValidator(BearerTokenValidator):
    def authenticate_token(self, token_string):
        return Token.query.filter_by(access_token=token_string).first()

require_oauth = ResourceProtector()
require_oauth.register_token_validator(MyBearerTokenValidator())

@app.route("/user")
@require_oauth("profile")
def user_profile():
    return jsonify(current_token.user)

Scope lists combine differently than people expect: ['profile email'] means both scopes, ['profile', 'email'] means either one.

Serve an endpoint that works with or without a tokenoptional-auth-endpoint

@app.route("/timeline")
@require_oauth(optional=True)
def timeline_api():
    if current_token:
        return get_user_timeline(current_token.user)
    return get_public_timeline()

With optional=True an invalid token is still rejected; only a missing token falls through to the public branch.

Move JWT code off the deprecated authlib.jose modulemigrate-jose-to-joserfc

# old, deprecated:
# from authlib.jose import jwt
# claims = jwt.decode(token_string, key)
# claims.validate()

from joserfc import jwt, jwk

key = jwk.import_key("your-secret-key", "oct")
encoded = jwt.encode({"alg": "HS256"}, {"sub": "user-1"}, key)

token = jwt.decode(encoded, key)
claims_requests = jwt.JWTClaimsRegistry(iss={"essential": True})
claims_requests.validate(token.claims)

joserfc is a separate package from the same author and is already an Authlib dependency. Decoding without a claims registry checks the signature but not exp, iss, or aud, which is how signature-valid but expired tokens get accepted.

Send the user through RP-initiated logoutoidc-logout

@app.route("/logout")
async def logout(request):
    id_token = request.session.pop("id_token", None)
    return await oauth.google.logout_redirect(
        request,
        post_logout_redirect_uri=str(request.url_for("logged_out")),
        id_token_hint=id_token,
    )

@app.route("/logged-out")
async def logged_out(request):
    await oauth.google.validate_logout_response(request)
    return PlainTextResponse("You have been logged out.")

This only works if the provider publishes an end_session_endpoint in its metadata, and you must have stored the id_token at login to pass it as the hint.

Alternatives

PackageRegistryPick it when
joserfcPyPIYou only need JWS, JWE, JWK, or JWT; it is the maintained successor to the deprecated authlib.jose module
pyjwtPyPIYou just encode and decode JWTs in your own service and want the smallest, most widely reviewed option
requests-oauthlibPyPIYou are only an OAuth client on requests and prefer the older oauthlib stack your team already knows
django-oauth-toolkitPyPIYou want a Django OAuth 2.0 provider with models, views, and admin pages already wired up