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.
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.
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
- You only need to sign and verify JWTs: authlib.jose is deprecated with a migration guide pointing at joserfc, so install joserfc or PyJWT directly instead of pulling in the whole OAuth stack
- You cannot commit to fast upgrades: Authlib shipped a run of security advisories through 2025 and 2026, including a critical JWS JWK header injection signature bypass fixed in 1.6.9 and open redirect fixes in 1.6.10 through 1.7.1, so running an old pin is a real risk
- You want a batteries-included provider with admin UI, consent screens, and migrations: Authlib is a library of pieces, and building a production authorization server on it means writing your client, token, and grant storage models yourself
- You are on Django and want the shortest path to an OAuth provider: django-oauth-toolkit gives you models, views, and admin out of the box, where Authlib gives you spec primitives
- You are stuck on Python 3.9 or older: current releases require Python 3.10+
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 expiredWithout 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
| Package | Registry | Pick it when |
|---|---|---|
| joserfc | PyPI | You only need JWS, JWE, JWK, or JWT; it is the maintained successor to the deprecated authlib.jose module |
| pyjwt | PyPI | You just encode and decode JWTs in your own service and want the smallest, most widely reviewed option |
| requests-oauthlib | PyPI | You are only an OAuth client on requests and prefer the older oauthlib stack your team already knows |
| django-oauth-toolkit | PyPI | You want a Django OAuth 2.0 provider with models, views, and admin pages already wired up |