Designing an API that scales to a million captive portals.
Seven web API design assumptions captive portals break: cloud RADIUS, walled gardens without CDNs, one-shot bootstraps and roaming without a controller.
A phone joins a network it cannot use yet, opens a strange little browser, and asks us who it needs to be. That happened more than seven million times in the last twelve months, across four continents, against the five regions of Halo Cloud, and every one of those moments lives in a genuinely odd corner of distributed systems: the captive portal, where the assumptions that underpin normal web API design fail one by one.
Seven of them, by our count. This post walks through each in the order a guest meets them, and it ends where those failures eventually pushed us: writing our own captive portal engine, HaloChilli, because the open-source one the whole industry starts on could not survive the seventh.
The constraint that shapes everything: we are the cloud
One piece of context first, because it explains why we meet these assumptions harder than most. The typical WiFi vendor serves the captive portal from a box in the venue’s rack: an on-premise controller that hosts the splash page, terminates the login, runs DHCP, and anchors the session. The portal is a LAN hop from the guest. Latency is a rounding error and scale is bounded by the size of the building.
We made the opposite bet. HaloWiFi is a cloud-managed platform, which means the portal loads from our cloud and the authentication decision, RADIUS, is answered from our cloud too. There is no controller in the rack, on purpose; the reasons live under assumption seven. The consequence is that every design question becomes a latency-and-reliability question. A guest in Kampala or Toronto is not talking to hardware ten metres away; they are talking to infrastructure we run. So we run it close, five regions, guests routed to the nearest, and that flattens the geography. The seven assumptions are what remains.
Assumption one: the client is a browser
It is not. The captive portal’s client is the OS’s captive network assistant, the mini-browser iOS and Android summon when their connectivity probes fail. It is a hostile runtime: stripped-down, impatient, without the persistence guarantees of a real browser, liable to be dismissed by the user with one swipe, and quick to conclude that the network is broken and give up on the guest’s behalf.
And everything it does, it does on a network that is, by design, not working.
Assumption two: the network underneath you works
Ours deliberately does not. The guest is pre-authentication, inside a walled garden that permits exactly one conversation: the one with the portal. Underneath that sits congested venue RF, the worst network path the guest will use all day. Every request you add to the login flow crosses that path, inside a browser that is looking for an excuse to leave.
The design conclusion is blunt, and it governs everything downstream: the number of network round trips between “phone joins SSID” and “guest is online” is the metric. Everything else in this post serves it.
The reflexive first move, then, is to get the heavy static assets close to the guest. Push it all to a CDN. Which runs straight into the next broken assumption.
Assumption three: static assets are the CDN’s problem
For a captive portal they cannot be, and understanding why explains half of our serving architecture.
The walled garden is a firewall allowlist on the access device: pre-auth guests can reach the portal’s endpoints and nothing else. Modern CDNs are close to un-allowlistable. Their edges live on vast, rotating IP ranges; their certificates and IPs are shared across thousands of co-tenants; the “location” of an asset is deliberately fluid. You can chase their published ranges, but then your garden gate is only as stable as someone else’s network topology. And it is worse than fragile: allowlisting a shared CDN edge quietly allowlists everything else served from it. A pre-auth guest who can reach a whole CDN has a tunnel out of your garden, and free-riders have been exploiting exactly that hole in poorly built portals for years.
So we serve every byte of the portal ourselves, from our own regional origins, on a small, stable, allowlistable set of endpoints that the access hardware and the cloud agree on. That decision costs us the world’s edge caches, which means we get our performance the older way: by shipping almost nothing. Assets are trimmed to what the theme actually needs and the payload is treated like it is 2005 and every kilobyte is billed. When you cannot lean on somebody’s edge, weight is architecture.
A light page still has to talk to an API, though. So the next thing we rationed was the talking.
Assumption four: round trips are cheap
On this path they are the most expensive thing there is, so the API design follows: ask once, receive everything, work locally.
When a portal instance loads, it makes a single bootstrap request, and the response carries as much as the flow could plausibly need: venue identity and theming, the enabled login methods and their parameters, policy the flow must respect, localisation, and the state of this particular client’s journey so far. From there the flow runs client-side. Screen transitions, validation, method selection, all of it happens without touching the network again. The network is bothered when something real happens: credentials submitted, a code redeemed.
The same principle killed multi-step logins in our defaults. Every additional step in a portal flow is not just UX friction; it is another opportunity for the mini-browser to time out or the user to swipe away. The default flows are engineered to be one honest interaction long.
Which holds beautifully, right up until the code running that interaction is not ours.
Assumption five: the code calling your API is yours
Frequently, it is not. We do not force our portal flows on anyone: venues and partners can build fully custom login experiences against our engine, their brand, their steps, their logic, and they do, elaborately. Which means the most latency-critical screen in our system regularly runs code we did not write, by developers who have not read the guidelines, because nobody reads the guidelines.
You cannot fix that with documentation. You fix it with the shape of the handover. Before custom code takes over, the engine hands it a complete, structured context: everything the bootstrap knows, already validated, already typed, already localised, the client’s state machine position included. The custom flow starts life holding everything it needs, so its most common mistakes, re-fetching what it already has, guessing at state, improvising extra chatter, are mistakes it never gets pushed toward. The pit of success is carved so that lazy code lands in it.
We think of it as designing for the API consumer’s worst day, not their best one. The integrations that make us proudest are the ones that misbehave gracefully. But however well anyone’s flow behaves, ours or theirs, none of it is real until RADIUS says so.
Assumption six: the client can wait while you do the bookkeeping
Not at the gate, it cannot. The access device asks our cloud “may this client pass,” and the Access-Accept that comes back is the moment the walled garden opens. That request is the most latency-sensitive packet in the entire platform, and our rule for it is: the authorization decision must be cheap, and everything expensive must be somewhere else.
The RADIUS fast path does the minimum work to answer the only question that matters, against state that is already prepared for it. Session records, accounting, analytics, the bookkeeping that makes dashboards useful, all of that is decoupled and can land a few hundred milliseconds later without any guest noticing, because the NAS holds the client’s session state locally the moment access is granted. The cloud closes the loop; it does not sit in the data path. A design that makes a guest wait on a database write it did not need to wait on has its priorities inverted.
Granting access this way leaves the session state living on the access device that granted it. Which raises the question that ended up reshaping our whole product: what happens to that state when the guest walks?
Assumption seven: the session lives somewhere
At single-AP scale, it does, and nothing above is remarkable. The problems arrive with size: a resort, a campus, a venue with dozens of access points, where a guest authenticates once at reception and then moves.
The industry’s standard answer is the on-premise controller. It anchors the guest session, often runs DHCP, and because every AP hangs off it, roaming is “solved” by centralising everything through one box. It is also an extra cost the customer pays before the first guest connects, a sizing decision made on day one that the deployment will outgrow, and a single point of failure sitting in a closet. We refused to build our architecture around selling one.
The alternative we chose is solution architecture over hardware: let the fleet itself carry the knowledge. Every Halo device, APs, cloud switches, shares authorized-client state with its peers. When a guest who authenticated at the lobby AP appears at the pool AP, that device already knows this client is authorized and admits them automatically. No portal re-splash, no second login, no detour to anywhere before the guest’s music resumes. The session follows the human, and adding the fortieth AP is the same operation as adding the fourth. Easier life for the customer, considerably more complicated engineering for us, which is the correct direction for that trade to flow.
This is the assumption that broke our tooling. We started, like almost everyone in this space, on CoovaChilli, the venerable open-source captive portal daemon, and it served us well right up to this wall. Its worldview is fundamentally single-device: one NAS, holding its own sessions, making its own decisions. Distributed authorized-state, the thing our roaming model runs on, is not a feature you bolt onto that worldview; it is a different worldview. So we wrote HaloChilli, our own captive portal engine, built from the first line around state that is shared, synchronised, and fast to consult on the access device itself. The name keeps the lineage; the architecture does not.
What a million means
“A million captive portals” is not one load test; it is the same guest-shaped problem repeated across thousands of venues in different countries, on different regulations, with different login flows, hitting whichever of our five regions is nearest, frequently all at once, because events synchronise humans and humans synchronise load. The architecture above is what makes that shape survivable: serve everything yourself so the garden stays sealed, ask the network for as little as possible, keep other people’s code well-fed and off the hot path, answer the gate question instantly and do the bookkeeping later, and let the fleet share the state a controller would have hoarded.
None of it is exotic. All of it is the accumulated residue of watching real guests connect on four continents and being unwilling to make any of them wait. If you are building against our platform, the API docs show what the bootstrap hands you. If you are evaluating us, this post is the level of thinking underneath every feature we ship. And if you just run venues: book a demo, and the next phone that joins and asks who it needs to be will get its answer before it finishes asking.