Contents
With python-pay-ccavenue extracted and payments finally living in a package I trusted (chapter 5), the Django platform had stopped being a rescue operation and started being a foundation. Which was exactly when the shape of the real problem came into focus — and it wasn’t a backend problem at all.
Happy Thoughts was never going to be one app. The foundation wanted a public site to browse courses, a separate learner app to actually watch them, a Hindi-first experience distinct from the English one, a landing site per major course launch, a donations site, a react-admin panel for the content team, an events microsite, and — because most of India consumes video on a phone — a native Android app. Off to the side, the same organisation ran Ambarsthan (a bookable facility) and an internal ERP for departments and staff. By the time I counted honestly, we were looking at roughly twenty Next.js web apps plus an admin panel plus Android, all of which needed the same three things: the same content, the same user identity, and the same payments.
The question that decides your architecture for the next three years is deceptively small: do these twenty front-ends each get their own backend, or do they all talk to one?
The two ways to build this
There are two honest answers, and picking wrong is expensive in opposite directions.
Backend-per-app is the microservices instinct: every front-end owns its own service and database, teams move independently, one app falling over doesn’t take the others down. It sounds like maturity. But look at what it forces on our situation. A user who paid for a course on the learner app must be recognised as having paid on the Android app and in the admin panel — so now “who paid for what” has to be synced across twenty databases, or hidden behind a shared service that all twenty call, which means it wasn’t really twenty backends after all. Every one of those services would re-implement CCAvenue, re-implement auth, re-implement the course model. For a non-profit with a small, rotating team, backend-per-app is a headcount you don’t have, spent solving a coordination problem you don’t need to have.
One shared backend — a single Django project that every front-end talks to — inverts all of that. Content, identity, and payments are defined once. A payment recorded by the Django hub is instantly true everywhere, because there is only one place it can be true. The cost is the obvious fear: that one backend quietly congeals into a big ball of mud — a system where everything depends on everything, no boundary is real, and a change to donations mysteriously breaks bookings. That fear is legitimate. It’s also manageable with discipline, whereas the coordination cost of twenty backends is structural and permanent.
I chose one hub. The bet was that a monolith kept deliberately modular beats a distributed system kept accidentally coupled — especially when your real scarcity is people, not servers.
Hub-and-spoke, and why the shape matters
The pattern I built to is hub-and-spoke. Picture a bicycle wheel: a strong hub at the centre, many spokes radiating out to the rim, and — critically — the spokes never connect to each other. They only connect to the hub.
The Django backend is the hub. It owns the hard, shared, correctness-critical things: the content models, the payment ledger, the enrolment records, the permission rules. Each front-end — every Next.js app, the react-admin panel, the Android app — is a spoke. A spoke is deliberately thin: it renders an experience and talks to the hub over the same DRF API everyone else uses. Spokes never talk to each other and never touch the database directly. If the Hindi learner app needs to know whether you’re enrolled, it doesn’t ask the English app or read a shared table — it asks the hub, exactly the way Android does.
That “spokes don’t touch each other” rule is the whole game. It’s what lets you add the twenty-first front-end in a weekend without auditing the other twenty, and it’s what stops the mud: a spoke can’t create a hidden dependency on another spoke, because it has no way to reach one.
flowchart TD
subgraph Center[The Hub]
D[Django plus DRF<br/>content payments enrolments permissions]
K[Keycloak<br/>one realm one identity]
end
D <--> K
W1[Public course site] --> D
W2[Learner app Hindi and English] --> D
W3[Donations and launch microsites] --> D
A[react-admin panel] --> D
M[Android app] --> D
W1 -.->|login| K
W2 -.->|login| K
A -.->|login| K
M -.->|login| KThe identity problem, and what an auth realm actually is
Content and payments living in one hub is the easy half. The half that makes or breaks a multi-app platform is identity. If a learner signs into the public site, then opens the learner app, then installs Android, they must be the same person to the system every time — same account, same purchases, same progress — and they should not have to type a password three times.
The naive approach is to put a users table and a login form in the Django hub and call it done. That works for one app. It falls apart at twenty, because now every spoke needs to handle login, session cookies, password resets, token refresh, “sign in with Google”, and eventually two-factor — and you’re re-implementing the single most security-sensitive code in your platform, slightly differently, twenty times. That is precisely where breaches come from.
So I pulled identity out of Django entirely and gave it to a dedicated system: Keycloak, an open-source identity provider. Two terms do the heavy lifting here, so let me define them plainly.
A realm is Keycloak’s word for one self-contained universe of identity: one set of users, one set of login rules, one set of applications that trust it. Everything inside a realm shares an account namespace; things in different realms know nothing about each other. For Happy Thoughts I ran one realm — every front-end, the admin, and Android are all registered inside it. Ambarsthan and the ERP, being the same organisation’s staff and members, live in that same identity universe too. One realm is the technical reason a single account is genuinely one person across the entire platform.
SSO — single sign-on — is the payoff a shared realm buys you: authenticate once, and every app that trusts that realm accepts you without a fresh login. You’ve felt this every time signing into Gmail also signs you into YouTube. The mechanism is a token. When you log in, Keycloak hands your browser a short-lived, cryptographically signed access token — think of it as a tamper-proof wristband that says “this is user 4471, and these are their roles.” Each spoke sends that wristband to the Django hub on every request. Django doesn’t need to check a password or call Keycloak; it just verifies the signature (which only Keycloak could have produced) and trusts the contents. Stateless, fast, and identical for a Next.js app and for Android.
sequenceDiagram
participant U as User in a spoke
participant K as Keycloak realm
participant D as Django hub
U->>K: Log in once with email and password
K-->>U: Signed access token the wristband
U->>D: Request courses with token attached
D->>D: Verify token signature locally
D-->>U: Content this exact user is entitled to
U->>K: Open a different spoke later
K-->>U: Already signed in no password neededThe operational relief here is hard to overstate. Password resets, social login, session expiry, brute-force lockouts, and later multi-factor auth are all configured once, in Keycloak, and every current and future spoke inherits them for free. When we added “sign in with Google,” no front-end shipped a line of code. That is centralising the hard thing so the experiences can stay cheap.
What the hub actually does with the wristband
“Verify the signature and trust the contents” is the one sentence that has to be correct, because it is the only thing standing between a request and the payment ledger. So here is the real shape of it, not the hand-wave.
Keycloak signs its access tokens with RS256 — an asymmetric signature. Keycloak holds a private RSA key that only it can sign with; it publishes the matching public keys at a well-known JWKS endpoint (JSON Web Key Set). The Django hub fetches those public keys once, caches them, and from then on can verify any token offline. No network round-trip to Keycloak on the hot path, no shared secret to leak: the hub can prove a token is authentic but could never forge one, because it never sees the private key. Each key in the JWKS carries a kid (key id); the token’s header names the kid it was signed with, so the hub knows which public key to check against.
That is a DRF authentication class. It runs on every request, before any view code:
# accounts/authentication.py
import jwt
from jwt import PyJWKClient, InvalidTokenError, PyJWKClientError
from django.conf import settings
from rest_framework import authentication, exceptions
from .models import User
class KeycloakOIDCAuthentication(authentication.BaseAuthentication):
"""Validate an RS256 access token from the single Keycloak realm.
No DB session and no call back to Keycloak on the hot path: an intact
RS256 signature proves the token is authentic, because only the realm
holds the private key. Identical code path for Next.js and Android.
"""
# One JWKS client per process. It pulls the realm's public keys from the
# well-known endpoint and caches them keyed by `kid`.
_jwks_client = PyJWKClient(
settings.KEYCLOAK_JWKS_URL, # https://id.example.org/realms/happy/protocol/openid-connect/certs
cache_keys=True,
max_cached_keys=16,
)
def authenticate(self, request):
header = authentication.get_authorization_header(request).split()
if not header or header[0].lower() != b"bearer":
return None # not our scheme; DRF returns 401 only if every authenticator passes
if len(header) != 2:
raise exceptions.AuthenticationFailed("Malformed Authorization header.")
claims = self._decode(header[1].decode())
return (self._sync_user(claims), claims)
def _decode(self, raw_token):
signing_key = self._signing_key_for(raw_token)
try:
return jwt.decode(
raw_token,
signing_key.key,
algorithms=["RS256"], # pin the alg; never trust the token header's `alg`
audience=settings.KEYCLOAK_AUDIENCE, # this token was minted for our client id
issuer=settings.KEYCLOAK_ISSUER, # https://id.example.org/realms/happy
leeway=30, # tolerate <=30s of clock skew on exp/iat/nbf
options={"require": ["exp", "iat", "aud", "iss"]},
)
except jwt.ExpiredSignatureError:
raise exceptions.AuthenticationFailed("Token expired.")
except InvalidTokenError as exc:
raise exceptions.AuthenticationFailed(f"Invalid token: {exc}")
Three lines in that jwt.decode are load-bearing and worth saying out loud. algorithms=["RS256"] pins the algorithm — you never read alg out of the token itself, or an attacker sends alg: none (or an HS256 token signed with the public key as the HMAC secret) and walks straight in. audience rejects a token that was minted for a different client in the same realm. issuer rejects a token from a different realm entirely. Drop any one of those checks and “verify the signature” quietly stops meaning “this request is who it claims to be.”
The user side is deliberately thin. The realm is the source of truth for identity; Django keeps a shadow row only so foreign keys — enrolments, payments, bookings — have something to point at:
def _sync_user(self, claims):
# `sub` is the stable Keycloak user id. Never key off email: emails change,
# and two realms could reuse one. `sub` is the one immutable handle.
user, _ = User.objects.get_or_create(
keycloak_sub=claims["sub"],
defaults={
"email": claims.get("email", ""),
"full_name": claims.get("name", ""),
},
)
return user
Roles ride inside the token too — Keycloak puts them under realm_access.roles — so “is this the content team’s admin?” is answered from the verified claims, not a second lookup. That is exactly how the react-admin panel gets its powers without a privileged backdoor: same token, same verification, a content-admin role in the claim set. Wiring it into DRF is one settings block:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"accounts.authentication.KeycloakOIDCAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
The two failure modes that actually bite: clock skew and key rotation
Two things break token verification in production, and neither is exotic — they’re the boring realities of running RS256 across many machines.
Clock skew. A token’s exp and iat are absolute timestamps. If the hub’s clock and Keycloak’s clock drift even a couple of seconds apart, a freshly minted token can look like it was issued in the future, or a still-valid one can look expired — and legitimate users get spurious 401s. That’s the leeway=30 above: a small tolerance window. (The real fix is NTP on every box; leeway is the seatbelt for when it slips.)
Key rotation. Keycloak rotates its signing keys periodically, and hard on an incident. When it does, tokens start arriving signed with a new kid that isn’t in the hub’s cached JWKS — and a naive verifier fails every request until it’s restarted. The fix is to treat an unknown kid as a cache-miss, not an error: refetch the JWKS once and retry.
def _signing_key_for(self, raw_token):
try:
return self._jwks_client.get_signing_key_from_jwt(raw_token)
except jwt.DecodeError as exc:
# Garbage bearer token: PyJWT can't even parse the header to read `kid`.
# Scanners and stale clients send these constantly — fail as 401, not 500.
raise exceptions.AuthenticationFailed("Malformed token.") from exc
except PyJWKClientError as exc:
# Unknown `kid`: keys rotated since we last cached. Refetch once, retry once.
self._jwks_client.fetch_data()
try:
return self._jwks_client.get_signing_key_from_jwt(raw_token)
except PyJWKClientError:
raise exceptions.AuthenticationFailed("Unknown signing key.") from exc
Two catches, in that order. jwt.DecodeError fires when the token is malformed enough that PyJWT can’t even parse its header to find the kid — attacker probes, truncated tokens, a stale client sending junk. Left uncaught it escapes authenticate, and DRF’s default exception handler doesn’t recognise it, so the request 500s instead of cleanly 401-ing. PyJWKClientError is the well-formed but unknown-key case that drives the rotation retry. One refetch-and-retry, not a retry loop — an unknown kid after a fresh JWKS pull is a genuinely bad token, not a rotation, and should fail fast.
Letting twenty browser origins call one hub
There’s a browser-shaped wrinkle the Android app never hits. Each Next.js spoke runs on its own origin, and a browser will refuse a cross-origin XHR to the hub unless the hub explicitly opts that origin in. So the single realm gives you one login; CORS (Cross-Origin Resource Sharing) is the browser-side handshake that lets those origins actually call the hub. One allow-list, every spoke on it:
# settings.py (django-cors-headers)
from corsheaders.defaults import default_headers
CORS_ALLOWED_ORIGINS = [
"https://happythoughts.example.org", # public course site
"https://learn.example.org", # learner app - English
"https://seekho.example.org", # learner app - Hindi
"https://give.example.org", # donations
"https://admin.example.org", # react-admin panel
"https://ambarsthan.example.org", # facility bookings
"https://erp.example.org", # internal ERP
# ... ~13 more launch and course microsites
]
# The token travels in the Authorization header, never a cookie, so we do NOT
# want credentialed CORS - that avoids the Allow-Credentials-plus-wildcard
# footgun entirely, and keeps auth stateless across every origin.
CORS_ALLOW_CREDENTIALS = False
CORS_ALLOW_HEADERS = list(default_headers) + ["authorization"]
Because auth lives in a header rather than a cookie, the whole thing stays stateless: no SameSite gymnastics, no CSRF surface on the API, and the Android app — which isn’t a browser and ignores CORS completely — sends the exact same Authorization: Bearer header to the exact same endpoints. One realm, one verification path, one set of endpoints; the only difference between a Next.js spoke and Android is who’s holding the wristband.
Two token lifetimes make this safe and pleasant. Access tokens are short-lived (~5 minutes) so a leaked one is worthless fast; a longer-lived refresh token lets the spoke get a new access token without dragging the user back to a login screen. The dance is the same for a browser spoke and for Android:
sequenceDiagram
participant B as Browser spoke
participant K as Keycloak realm
participant D as Django hub
B->>K: Auth code exchange after login
K-->>B: Access token 5 min plus refresh token
B->>D: API call with access token
D->>D: Verify RS256 against cached JWKS
D-->>B: 200 entitled content
B->>D: Later call token now expired
D-->>B: 401 token expired
B->>K: Refresh token grant
K-->>B: Fresh access token no re-loginKeeping one hub from becoming one mess
Choosing a monolith is not the same as choosing a mess. The discipline that kept the hub honest was ordinary and boring, which is the point:
- Apps with real seams. Django’s app system is a gift here — the hub is genuinely “one backend, twenty-plus apps”:
courses,payments,enrolments,bookings(Ambarsthan),org(the ERP), each a Django app with its own models and its own DRF viewsets. Cross-app calls go through a thin, deliberate interface, not by reaching into another app’s tables. - One API contract, versioned. Every spoke consumes the same DRF API. There is no “special endpoint for Android.” When a payload has to change in a breaking way, it ships as a new version and the old one keeps serving until every spoke has moved — so a backend change never forces a synchronised, all-apps-at-once front-end deploy.
- The token is the only door. No spoke gets database credentials. The only way to read or write platform data is the authenticated API, which means every access path runs through the same permission checks. This mattered enormously later, when the ERP needed data-driven permissions (chapter 9) — because there was exactly one enforcement point to put them behind.
The subtle, unconventional call inside all this was refusing to give the react-admin panel a privileged backdoor. The content team’s admin talks to the same DRF API as the public site, just with an account that carries admin roles. It was more work up front than wiring react-admin straight to the database. It bought us one code path to secure, one place to reason about “can this user do this,” and an admin panel that could never drift into being a second, shadow API with its own bugs.
What it bought us
The proof of a hub-and-spoke bet isn’t in the diagram — it’s in how cheap the next front-end becomes. By the time this settled, standing up a new Next.js spoke — a launch microsite, a new donations flow, a Hindi-first variant — was a front-end task measured in days, not a full-stack project measured in weeks. It reused the same auth (log in via the realm), the same content API, the same payment flow, and inherited them working. New surfaces got cheap while the expensive, correctness-critical core stayed in exactly one place.
And that core scaled with the platform straight through the growth this series tracks — from those first couple of thousand users toward the tens of thousands still ahead — because growth arrived as more spokes and more traffic against one hardened hub, never as twenty backends drifting out of sync. When it mattered most, there was one place to fix a bug, one place to add a feature, and one identity that meant one person everywhere.
The lesson
Centralise the hard, shared, correctness-critical things — identity, payments, the domain model — into one hub you can actually reason about. Make the experiences many, thin, and cheap. The instinct to give every app its own backend feels like sophistication; for a small team it’s mostly self-inflicted coordination work. A monolith isn’t a liability if its seams are real and every spoke is forced through the same front door. Distribute your experiences, not your source of truth.
There was a catch waiting, though. Twenty front-ends doing real work — payments to reconcile, videos to process, emails to send, bookings to confirm — meant the hub was suddenly being asked to do many kinds of background work at once, and a slow video transcode could not be allowed to make a payment wait in line. Which is the next problem: giving each kind of work its own lane.
