A tiny SSO provider for personal subdomains
I run a handful of small web apps on subdomains — a finances dashboard, a map, a CV, a blog admin. Each had its own login. Each login was the same login. I wanted one passkey sign-in to cover all of them without standing up Keycloak or pulling in OAuth plumbing.
What was happening
Every new side project came with a tiny "auth" file. Some used basic auth, some used a session cookie, one had a half-built WebAuthn flow because I'd been experimenting. The drift was already painful. I had passkeys working well on the main assistant app, and I just wanted to delegate everything else to it.
What I found
The minimum viable IdP is small. Three things have to happen:
- The relying party (the dashboard, the CV, whatever) bounces the
user to the IdP with a
redirect_uriand astate. - The IdP checks the user's session and either issues a short-lived signed token or sends them to log in first.
- The relying party verifies the token, sets its own session cookie, and lets the user in.
No PKCE, no nonces, no JWT library — itsdangerous has a
URLSafeTimedSerializer that signs a small dict with a shared
secret and gives you free max-age enforcement on verify.
The fix
The IdP side, on the main app:
ALLOWED_SSO_HOSTS = {"financials", "map", "cv", "blog", "x"}
SSO_SIGNER = URLSafeTimedSerializer(
os.environ["SSO_SIGNING_SECRET"],
salt="cwfrazier-sso-v1",
)
@app.get("/sso/authorize")
async def sso_authorize(redirect_uri: str, state: str, request: Request):
rp_host = urlparse(redirect_uri).hostname or ""
rp_name = rp_host.split(".")[0]
if rp_name not in ALLOWED_SSO_HOSTS:
raise HTTPException(400, "unknown relying party")
if not has_valid_session(request):
# bounce to login, which honors ?next=
return RedirectResponse(
f"/?next=/sso/authorize?redirect_uri={quote(redirect_uri)}&state={quote(state)}",
status_code=307,
)
token = SSO_SIGNER.dumps({
"iss": "x.example.com",
"sub": "chester",
"aud": rp_host,
"iat": int(time.time()),
})
sep = "&" if "?" in redirect_uri else "?"
return RedirectResponse(
f"{redirect_uri}{sep}token={token}&state={state}",
status_code=302,
)
The login page parses ?next= and bounces back to it after a
successful passkey verification, so the user sees one passkey
prompt and lands on the relying party.
The relying party side is even smaller:
@app.get("/sso/callback")
def sso_callback(token: str, state: str, response: Response):
try:
payload = SSO_SIGNER.loads(token, max_age=300)
except (BadSignature, SignatureExpired):
raise HTTPException(401)
if payload.get("aud") != THIS_HOST:
raise HTTPException(401)
session = SESSION_SIGNER.dumps({"sub": payload["sub"]})
response.set_cookie("session", session, httponly=True, secure=True)
return RedirectResponse("/")
Adding a new app to the SSO ring is now three things:
- Append its hostname prefix to
ALLOWED_SSO_HOSTSon the IdP. - Set the same
SSO_SIGNING_SECRETenv var on the relying party. - Wire up
/sso/callback(the snippet above).
What I'd do differently
This works for one user and a handful of personal apps. It would not survive multi-user, scoped permissions, or third-party clients — there's no consent screen, no per-RP scopes, no refresh flow. I'm clear-eyed about that. If I ever need any of those, I'd move to a real OIDC library rather than try to grow this into one.
The shared-secret model also means every relying party has the verification key, so any one of them being compromised compromises the others. Acceptable for personal apps on the same domain. Not acceptable for anything bigger.