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.

Locality
North America and Europe
Disciplines
Identity and security, Product engineering
Stack
  • Python
  • Django
  • OAuth 2.0 / OIDC
  • WebAuthn
  • PostgreSQL
  • Docker

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

OIDC identity provider architectureArchitecture diagram with 9 components. Members and staff to Identity provider. Cloudflare Tunnel to Members and staff. Identity provider to PostgreSQL. Permissions plugin to Identity provider (read-only mirror). Identity provider to Staff dashboard. Identity provider to Website and store. Identity provider to Discord role sync. Identity provider to Platform services.Relying partiesread-only mirrorMembers and staffPasskey or password + TOTPCloudflare TunnelNo inbound portsIdentity providerDjango, OIDC + PKCEPostgreSQLUnpublishedPermissions pluginRanks stay authoritativeStaff dashboardWebsite and storeDiscord role syncPlatform services

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.