Back to blog
FILE 0xAB·A TINY SSO PROVIDER FOR PERSONAL SUBDOMAINS

A tiny SSO provider for personal subdomains

April 17, 2026 · sso, fastapi, passkeys, homelab

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:

  1. The relying party (the dashboard, the CV, whatever) bounces the user to the IdP with a redirect_uri and a state.
  2. The IdP checks the user's session and either issues a short-lived signed token or sends them to log in first.
  3. 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:

  1. Append its hostname prefix to ALLOWED_SSO_HOSTS on the IdP.
  2. Set the same SSO_SIGNING_SECRET env var on the relying party.
  3. 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.