A single sign-on identity provider for a community of thousands
Members and staff had two account stores, four separate linking systems and no single answer to who someone actually was. We designed and built one OpenID Connect issuer for every service, with passkeys from day one.
At a glance
- 3,284
people migrated into one identity
- 3,158
linked platform and chat identifiers
- 8
OIDC clients in production
- 60s
access-token lifetime
The challenge
Over the years the community had collected a website account store, a separate platform and staff store with its own password hasher, and four different ways of linking a platform account, a Discord account and a forum account. Rows had been hand edited and duplicates were everywhere.
Staff logins had no step-up and every new app meant another login form. The brief was blunt: signing in with the community's own account would be the only way into anything, whilst the platform's own permissions stayed exactly where they were.
Architecture
Swipe to explore the diagram
One issuer, one public path. Every relying party uses the authorization code flow with PKCE, and ranks are mirrored read-only from the platform's own permissions system.
What we built
Protocol
- Authorization code with PKCE (S256), confidential clients, and RS256 signing with published JWKS and key rotation
- Discovery, introspection, revocation and back-channel single sign-out
- 60-second access tokens with rotating refresh tokens
Authentication
- Discoverable passkeys (WebAuthn) and TOTP as second factors
- Step-up re-authentication for anything security sensitive, valid for 20 minutes
- Passkey-only accounts never check a password at all, so there's no password oracle to probe
Identity model
- A generic linked-identifier model covering platform accounts, Discord and a community forum, grouped into link sets so each linked account carries only its own roles
- One hasher that verifies passwords from both legacy stores, so nobody was forced into a reset
- Scoped read APIs for relying parties: links, ranks, staff and contact
Operations
- Its own VM on a different hypervisor host from the apps it serves, replicated, with failover and failback proven
- Nightly backups to NAS with daily, weekly and archive rotation
- Branded transactional mail sent through Microsoft Graph
Engineering decisions
Build on Django rather than buy
We read the source of Keycloak, Authentik, Zitadel and Ory before deciding. None handled an imported legacy hasher and a generic linked-identifier model without a fight, and django-oauth-toolkit gave us a well-trodden protocol core to build on.
Mirror permissions, never own them
The platform's permissions plugin stays the authority for ranks and staff. The identity provider mirrors them read-only and writes nothing back, so there's only ever one place to change a rank.
Discord connects, it never signs you in
A chat account is a useful link and a poor identity. It's connect-only, and it's never trusted as a join key for the platform's services.
Fail safe on reads
If a link read fails or goes stale, nobody loses a role because of it. Taking access away always needs positive evidence.
Keep the trust anchor apart
The identity host doesn't share a hypervisor with the services that depend on it, so a bad day for the app host isn't a bad day for sign-in.
Outcome
The identity provider went live in October and staff were onboarded two days later. All three main apps switched to it with the member import applied, and the first attended run of the Discord role sync corrected 350 purchased ranks across 76 members.