Python integration walkthrough
We’re going to build a tiny Flask app, from empty folder to real users signing in, using the knuckles-client SDK. By the end you’ll have:
- A
/sign-inpage that offers Google and magic-link. - Working OAuth callbacks for both.
- A
/meendpoint that’s protected by a real signed token. - Server-side cookie storage for the access token, with silent refresh.
The same pattern works for FastAPI, Django, Pyramid — anywhere you have HTTP handlers and a session store. Flask is just the most beginner-friendly to read.
Table of contents
- Before you start
- Step 1 — Set up the project
- Step 2 — Create the Knuckles client (one place)
- Step 3 — Build the app skeleton
- Step 4 — The sign-in page
- Step 5 — Wire up Google sign-in
- Step 6 — Wire up magic-link sign-in
- Step 7 — Protect a route
- Step 8 — Silent refresh
- Step 9 — Sign out
- Step 10 — Try the whole thing
- What you just built (in 70 lines of glue)
- Common patterns from here
- Where the SDK lives
Before you start
You need:
- Knuckles running and reachable. Either on your laptop (see Quickstart) or somewhere deployed.
- An app-client registered with
--allowed-origin http://localhost:5000. From the Knuckles repo:python scripts/register_app_client.py \ --client-id my-flask-app \ --app-name "My Flask App" \ --allowed-origin http://localhost:5000Save the
client_secretit prints. - Google OAuth set up in Knuckles (see Setup Guide, Part 3.2) with
http://localhost:5000/auth/google/callbackin the authorized redirect URIs.
Step 1 — Set up the project
mkdir my-flask-app && cd my-flask-app
python3.12 -m venv .venv
source .venv/bin/activate
pip install flask knuckles-client python-dotenv
Create .env:
KNUCKLES_URL=http://localhost:5001
KNUCKLES_CLIENT_ID=my-flask-app
KNUCKLES_CLIENT_SECRET=kn_xxxxxxxxxxxxxxxxxxxxx
FLASK_SECRET_KEY=any-long-random-string
The FLASK_SECRET_KEY is unrelated to Knuckles — Flask uses it to sign session cookies for storing your own session data (specifically, the refresh token).
Step 2 — Create the Knuckles client (one place)
Make knuckles.py — a thin module that owns the SDK instance.
# knuckles.py
"""Single source of the configured Knuckles client."""
from __future__ import annotations
import os
from knuckles_client import KnucklesClient
def get_client() -> KnucklesClient:
"""Build a process-singleton Knuckles client.
Returns:
The shared KnucklesClient.
"""
return KnucklesClient(
base_url=os.environ["KNUCKLES_URL"],
client_id=os.environ["KNUCKLES_CLIENT_ID"],
client_secret=os.environ["KNUCKLES_CLIENT_SECRET"],
)
client = get_client()
The SDK is safe to keep as a module-level singleton — it caches JWKS internally and is thread-safe.
Step 3 — Build the app skeleton
Make app.py:
# app.py
import os
from flask import Flask, render_template_string, request, redirect, url_for, session, jsonify, abort
from dotenv import load_dotenv
from knuckles import client
from knuckles_client.exceptions import KnucklesAuthError, KnucklesTokenError
load_dotenv()
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
@app.get("/")
def index():
if session.get("user_id"):
return f'<p>Signed in as {session["email"]}.</p><p><a href="/logout">Sign out</a></p>'
return '<a href="/sign-in">Sign in</a>'
if __name__ == "__main__":
app.run(port=5000, debug=True)
Run it:
flask --app app run --port 5000 --debug
Visit http://localhost:5000 — you should see “Sign in.” Now we’ll make the link actually work.
Step 4 — The sign-in page
Add a /sign-in route that offers two methods:
@app.get("/sign-in")
def sign_in():
return render_template_string("""
<h1>Sign in</h1>
<form method="post" action="/sign-in/magic-link">
<input type="email" name="email" placeholder="you@example.com" required />
<button>Email me a link</button>
</form>
<hr>
<a href="/sign-in/google">Sign in with Google</a>
""")
Step 5 — Wire up Google sign-in
Two routes: one starts the flow, one completes it.
@app.get("/sign-in/google")
def google_start():
"""Redirect the user to Google's consent page."""
result = client.google.start(
redirect_url=url_for("google_callback", _external=True),
)
# We don't need to keep the state — Knuckles validates it on complete().
return redirect(result.authorize_url)
@app.get("/auth/google/callback")
def google_callback():
"""Google sent the user back. Trade code+state for tokens."""
code = request.args["code"]
state = request.args["state"]
pair = client.google.complete(code=code, state=state)
_store_session(pair.access_token, pair.refresh_token)
return redirect("/")
The Python SDK exposes the start-and-complete pair on each ceremony sub-client (client.google, client.apple, client.magic_link, client.passkey). Refresh, logout, profile, and JWT verification sit at the top level — client.refresh, client.logout, client.me, client.verify_access_token.
The _store_session helper writes the access token and the user’s identity into Flask’s session cookie, and tucks the refresh token into a server-side store (we’ll use the session for both for simplicity, but in real apps you’d want a database):
def _store_session(access_token: str, refresh_token: str) -> None:
"""Persist the tokens after a successful sign-in.
Args:
access_token: The RS256 access token from Knuckles.
refresh_token: The opaque refresh token from Knuckles.
"""
claims = client.verify_access_token(access_token)
session["user_id"] = claims["sub"]
session["email"] = claims.get("email")
session["access_token"] = access_token
session["refresh_token"] = refresh_token
We use Flask’s signed-cookie session here for brevity. In a real app, store the refresh token server-side (in Postgres / Redis), keyed by your own session ID. The browser only ever sees the session ID — never the refresh token.
Step 6 — Wire up magic-link sign-in
Two routes again: one sends the email, one verifies the token.
@app.post("/sign-in/magic-link")
def magic_link_start():
"""Email the user a magic link."""
email = request.form["email"]
client.magic_link.start(
email=email,
redirect_url=url_for("magic_link_verify", _external=True),
)
return "Check your email for a sign-in link."
@app.get("/auth/verify")
def magic_link_verify():
"""Redeem the magic-link token from the URL."""
token = request.args["token"]
pair = client.magic_link.verify(token)
_store_session(pair.access_token, pair.refresh_token)
return redirect("/")
Try it: visit /sign-in, type your email, watch the Knuckles terminal for the link, paste it into your browser. You should land back on / signed in.
Step 7 — Protect a route
The pattern: read the access token, verify it locally, do something with claims["sub"]. Wrap it in a decorator:
from functools import wraps
def signed_in(view):
"""Decorator that requires a verified Knuckles access token.
Args:
view: The Flask view to wrap.
Returns:
The wrapped view that 401s on missing/bad tokens.
"""
@wraps(view)
def wrapped(*args, **kwargs):
token = session.get("access_token")
if not token:
return jsonify({"error": "not signed in"}), 401
try:
claims = client.verify_access_token(token)
except KnucklesTokenError:
# Try a silent refresh.
if not _try_refresh():
return jsonify({"error": "session expired"}), 401
claims = client.verify_access_token(session["access_token"])
request.user_id = claims["sub"]
request.email = claims.get("email")
return view(*args, **kwargs)
return wrapped
@app.get("/me")
@signed_in
def me():
return jsonify({"user_id": request.user_id, "email": request.email})
verify_access_token does the JWKS-cached signature check locally — no network call after the first verify of the process.
Step 8 — Silent refresh
When the access token expires (1h after sign-in), use the refresh token to get a fresh pair:
def _try_refresh() -> bool:
"""Trade the stored refresh token for a new pair.
Returns:
True if refresh succeeded; False if the user must sign in again.
"""
refresh = session.get("refresh_token")
if not refresh:
return False
try:
pair = client.refresh(refresh)
except KnucklesAuthError as exc:
if exc.code in {"REFRESH_TOKEN_REUSED", "REFRESH_TOKEN_EXPIRED"}:
# Either the token leaked and was used elsewhere (reuse-detected),
# or its 30-day window elapsed. Either way the user must sign in.
session.clear()
return False
raise
session["access_token"] = pair.access_token
session["refresh_token"] = pair.refresh_token # IMPORTANT: rotate
return True
Always store the new refresh token from the response. Knuckles rotates refresh tokens — the old one becomes invalid the moment you use it. If you forget to update your storage, the next refresh attempt raises KnucklesAuthError(code="REFRESH_TOKEN_REUSED") and the user is signed out everywhere.
The SDK doesn’t have a separate RefreshTokenReusedError class — both reuse and expiry surface as KnucklesAuthError and you switch on exc.code to tell them apart. The full code vocabulary lives in knuckles/core/exceptions.py.
Step 9 — Sign out
@app.get("/logout")
def logout():
"""Revoke the refresh token and clear the local session."""
refresh = session.get("refresh_token")
if refresh:
try:
client.logout(refresh)
except KnucklesAuthError:
pass # Already revoked / expired — fine, just clear locally.
session.clear()
return redirect("/")
client.logout is idempotent — if the token’s already used or expired, it’s a no-op.
Step 10 — Try the whole thing
flask --app app run --port 5000 --debug
Open http://localhost:5000:
- Click Sign in.
- Try Sign in with Google → land back on
/showing your email. - Click Sign out.
- Try the magic-link form → check the Knuckles terminal for the link → paste → land on
/again. - Hit
http://localhost:5000/me→ see your user ID and email as JSON.
That’s a complete integration. ✅
What you just built (in 70 lines of glue)
[ user ] → / → /sign-in → /sign-in/google → Google → /auth/google/callback
/sign-in/magic-link → Resend → /auth/verify
[ user ] → /me → signed_in decorator → verify_access_token (cached JWKS, local)
→ on 401: _try_refresh → client.refresh
[ user ] → /logout → client.logout + session.clear
The whole sign-in story in one page, with no Knuckles internals leaking into your code.
Common patterns from here
- Want to call Knuckles for the user profile? Use
client.me(access_token=session["access_token"]). It returns a fresh copy of the user’s profile (in case they updated it somewhere else). - Want to enroll passkeys? Add
client.passkey.register_begin()/client.passkey.register_complete()once a user is signed in. The walkthrough is in the TypeScript guide because you need browser JS to do the WebAuthn API call — the Python pattern is the same on the backend, with the browser doing the cryptography in between. - Want to log the user out of every device, not just this one? Use
client.logout_all(access_token=...). Hits/v1/logout/all, which kills every refresh token for the user.
See Recipes for these and more.
Where the SDK lives
Source: packages/knuckles-client-py/. PyPI: knuckles-client.
The SDK is small and readable — about 800 lines including tests. If you ever wonder “what does client.google.start actually do?”, you can read it in 30 seconds.