Get started
API scopes
Integration guides
Features
Troubleshooting
Python
Authlib is the de facto OIDC client library for Python — works with Flask, FastAPI, Django, and standalone scripts. The example below uses Flask, but the moving parts are the same in any framework.
For Django specifically, mozilla-django-oidc is more idiomatic and integrates with django.contrib.auth directly.
Install
python3 -m venv .venv
.venv/bin/pip install authlib flask requests
Use .venv/bin/python app.py (and .venv/bin/pip ...) rather than source .venv/bin/activate + bare python — that way shell aliases or pyenv shims can't silently route to a different interpreter.
On macOS, port 5000 is taken by AirPlay Receiver (System Settings → General → AirDrop & Handoff). The rest of this guide assumes port 5001 to avoid the collision. Either turn AirPlay Receiver off, or run with flask run --port 5001 and register http://localhost:5001/callback.
Register the provider
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, session, url_for
app = Flask(__name__)
# 32+ random chars, stable across restarts (otherwise existing sessions get invalidated).
app.secret_key = os.environ["FLASK_SECRET"]
oauth = OAuth(app)
oauth.register(
name="kenni",
client_id=os.environ["KENNI_CLIENT_ID"],
client_secret=os.environ["KENNI_CLIENT_SECRET"],
server_metadata_url="https://idp.kenni.is/<team-domain>/.well-known/openid-configuration",
client_kwargs={
"scope": "openid profile national_id offline_access",
"code_challenge_method": "S256",
},
)
server_metadata_url triggers discovery — Authlib reads the issuer's well-known document and configures every endpoint. code_challenge_method=S256 enables PKCE for both public and confidential clients (defence in depth).
Login route
@app.route("/login")
def login():
return oauth.kenni.authorize_redirect(url_for("callback", _external=True))
_external=True is required — Authlib needs an absolute redirect URI.
Callback route
@app.route("/callback")
def callback():
token = oauth.kenni.authorize_access_token()
session["userinfo"] = dict(token.get("userinfo") or {})
session["id_token"] = token["id_token"]
session["access_token"] = token["access_token"]
session["refresh_token"] = token.get("refresh_token")
return redirect("/")
token["userinfo"] is parsed from the verified ID token. authorize_access_token() validates the signature, iss, aud, and nonce for you. Casting to a plain dict keeps the value portable across session backends.
Saving id_token here makes it available later for RP-initiated logout. Register http://localhost:5001/callback as a redirect URI on your application in the developer portal. To derive it dynamically, use url_for("callback", _external=True).
Flask's default session is a signed-but-not-encrypted cookie capped at ~4KB. id_token + access_token + refresh_token + userinfo is in the 2–3KB range — close enough that any extra payload tips you over. Switch to server-side sessions (e.g. flask-session backed by Redis or filesystem) for anything beyond a demo. Storing tokens in an unencrypted cookie also means anyone with the cookie reads the tokens.
Calling your API
import requests
res = requests.get(
"https://api.example.com/orders",
headers={"Authorization": f"Bearer {session['access_token']}"},
)
Verifying access tokens
When your Python service receives a Kenni access token (because you requested a custom API scope), validate it locally against the JWKS from discovery — issuer, audience, expiry, signature, plus the scope check:
import requests
from authlib.jose import JsonWebKey, jwt
ISSUER = "https://idp.kenni.is/<team-domain>"
API_AUDIENCE = "<your-api-audience>"
API_SCOPE = "<your-api-scope>"
# Cache this — don't refetch the JWKS per request.
metadata = oauth.kenni.load_server_metadata()
keyset = JsonWebKey.import_key_set(requests.get(metadata["jwks_uri"]).json())
def verify_bearer(raw_bearer: str) -> dict:
claims = jwt.decode(
raw_bearer,
keyset,
claims_options={
"iss": {"essential": True, "value": ISSUER},
"aud": {"essential": True, "value": API_AUDIENCE},
"exp": {"essential": True},
},
)
claims.validate()
if API_SCOPE not in claims.get("scope", "").split():
raise PermissionError("missing required scope")
return claims
authlib.jose emits an AuthlibDeprecationWarning recommending joserfc instead. The API stays compatible until Authlib 2.0; pin Authlib if the warning is noisy, or migrate to joserfc for new code.
Client credentials (M2M)
For machine-to-machine calls, hit the token endpoint directly:
import requests
metadata = oauth.kenni.load_server_metadata()
res = requests.post(
metadata["token_endpoint"],
data={"grant_type": "client_credentials", "scope": API_SCOPE},
auth=(os.environ["KENNI_CLIENT_ID"], os.environ["KENNI_CLIENT_SECRET"]),
)
access_token = res.json()["access_token"]
For credentials with special characters, the form-urlencoded Basic auth from RFC 6749 §2.3.1 requires base64(quote(id) + ":" + quote(secret)) rather than requests' default auth= tuple. Build the Authorization: Basic … header by hand if you suspect special characters in your secret.
Refresh
new_token = oauth.kenni.fetch_access_token(
grant_type="refresh_token",
refresh_token=session["refresh_token"],
)
session["access_token"] = new_token["access_token"]
session["refresh_token"] = new_token.get("refresh_token", session["refresh_token"])
fetch_access_token does not mutate the session — write the new token back yourself.
FastAPI / Starlette
Authlib has a Starlette integration that drops straight into FastAPI:
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse
app = FastAPI()
oauth = OAuth()
oauth.register(name="kenni", server_metadata_url="...", client_kwargs={...})
@app.get("/login")
async def login(request: Request):
return await oauth.kenni.authorize_redirect(request, request.url_for("callback"))
@app.get("/callback")
async def callback(request: Request):
token = await oauth.kenni.authorize_access_token(request)
request.session["userinfo"] = dict(token.get("userinfo") or {})
request.session["id_token"] = token["id_token"]
return RedirectResponse("/")
The route bodies are otherwise identical — just async, with request.session instead of Flask's global session, and RedirectResponse instead of redirect().
Public clients
For SPA or Native flows, omit client_secret. Authlib uses PKCE (already enabled via code_challenge_method) as the proof on the token request. PKCE is also recommended for confidential clients — defence in depth against authorization-code interception.
Sign out
session.clear() kills your tokens but leaves the Kenni session alive — the next sign-in silently re-authenticates. Read end_session_endpoint from the cached discovery metadata and redirect through it. The id_token was saved in the callback above.
from urllib.parse import urlencode
@app.route("/logout")
def logout():
id_token = session.get("id_token")
metadata = oauth.kenni.load_server_metadata()
end_session = metadata["end_session_endpoint"]
session.clear()
params = urlencode({
"id_token_hint": id_token,
"post_logout_redirect_uri": url_for("index", _external=True),
"client_id": os.environ["KENNI_CLIENT_ID"],
})
return redirect(f"{end_session}?{params}")
Register the resolved post-logout URL on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.
oauth.kenni.load_server_metadata() returns the cached discovery doc — use it to read any field (token_endpoint, jwks_uri, end_session_endpoint, etc.) without a re-fetch.