Flex Engineers
5 min read

Navigator: Building a Captive Portal Control Plane for MikroTik Hotspots

Next.jsExpressNetworkingPrismaSystem IntegrationDatabase Architecture

The problem: turning a router into a product

Most captive-portal Wi-Fi — the kind you hit at a cafe or an airport — is stitched together out of whatever the router firmware ships with: a clunky login page, static credentials, no real user data. Navigator started from a different premise: MikroTik's RouterOS already has a solid hotspot implementation, so instead of fighting it, our team built a control plane around it. A user connects to the Wi-Fi, gets redirected to a branded page, types a phone number, and Navigator provisions a real hotspot account on the router on their behalf — no pre-shared passwords, no manual admin work, no changes to the router's own AAA logic.

That framing — "the router is the enforcement point, we're the control plane" — shaped every architectural decision in the project.

System shape: five repos, two auth paths

Navigator isn't one app; it's a small constellation of services that split cleanly along the client/router boundary:

RepoRoleStackStatus
navigator-consumer-clientCaptive portal UI served to the user's browserNext.js 15 / React 19Working prototype
navigator-consumer-apiProvisions hotspot users on MikroTik via the RouterOS APIExpress + Prisma (Postgres)Working prototype
navigator-consumer-radiusStandalone RADIUS (AAA) server, evaluated as an alternative auth pathExpress + raw UDP dgram + PrismaEarly prototype
navigator-enterprise-apiBackend for a planned "enterprise" product tierExpress, JWT/argon2Scaffold
navigator-enterprise-clientEnterprise front end—Not started

The consumer flow is the one that runs end to end, and it's worth walking through because the interesting engineering is in how little state the client has to hold.

The consumer flow

When a MikroTik hotspot intercepts a client, it redirects the browser to a login page and hands it a set of query parameters: the client's MAC address, IP, and the original URL it was trying to reach. Navigator's Next.js app captures that handoff in a route handler:

// navigator-consumer-client/src/app/api/capture/route.js
export async function POST(req) {
  const body = Object.fromEntries(new URLSearchParams(await req.text()));
  const { userMAC, userIP, linkOrig, linkLoginOnly } = body;

  const loginResult = await loginByMac(userMAC);

  if (loginResult?.success) {
    // returning device — auto-submit the router's own login form
    redirectUrl = `${NEXT_URL}/login-auto?username=${loginResult.userId}&password=${loginResult.password}...`;
  } else {
    // first-time device — show the phone-number form
    redirectUrl = `${NEXT_URL}/login?...`;
  }

  return Response.redirect(redirectUrl, 302);
}

This single check — "does a hotspot user already exist for this MAC?" — is what makes repeat visits invisible. A returning device skips the phone-number form entirely and gets silently re-authenticated against the router. A new device goes through a short form that posts the phone number, MAC, and IP to navigator-consumer-api, which is where the actual provisioning happens:

  1. Validate the phone number, MAC, and IP.
  2. Generate a random password and encrypt it (more on that below).
  3. Store a ClientUser row in Postgres via Prisma.
  4. Call the RouterOS API to create the corresponding hotspot user on the MikroTik device itself:
// simplified — mikrotik.service.generateNewHotspotUser()
await routeros.write('/ip/hotspot/user/add', [
  `=name=${userId}`,
  `=password=${plaintextPassword}`,
  `=mac-address=${mac}`,
]);

From that point on, RouterOS owns the actual authentication decision for the hotspot session — Navigator's job was only to get the right credentials onto the router before the client needed them.

Encryption instead of hashing — a deliberate trade-off

The one design choice worth calling out explicitly: ClientUser.password is stored as AES-256-GCM ciphertext, not a bcrypt/argon2 hash, using a per-record IV and auth tag:

// encryption.service.js
const encrypt = async (text) => {
  const key = await generateKey(process.env.ENCRYPTION_KEY); // scrypt-derived
  const iv = randomBytes(16);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
  return { encryptedText: encrypted, iv: iv.toString('hex'), authTag: cipher.getAuthTag().toString('hex') };
};

At a glance, storing reversible ciphertext instead of a one-way hash for a password field looks like a bug. It isn't. The RouterOS /login call needs the original plaintext password to hand back to the router when a returning device is auto-logged in — a hash can't be turned back into a password, so hashing simply wasn't an option here. The honest trade-off is that anyone with the encryption key, salt, and database access can recover every hotspot password. For short-lived, auto-generated hotspot credentials that only exist to satisfy RouterOS's own login form, our team judged that an acceptable trade — but it's the kind of decision that needs a comment in the code and a line in the architecture doc, not silent cleverness, because the next engineer to touch it will assume it's a mistake.

The RADIUS detour

Alongside the RouterOS-API path, navigator-consumer-radius implements the standard alternative: a real RADIUS AAA server, built directly on Node's dgram UDP socket rather than a framework, using the radius package to encode/decode Access-Request and Access-Accept packets on port 1812. It has its own Prisma schema with bcrypt-hashed passwords — a completely separate user table from the consumer-api's ClientUser.

Evaluating both approaches side by side was the point:

  • RouterOS API provisioning (current live path): Navigator calls the router directly to create hotspot users. Simple, no protocol implementation needed, but tightly coupled to MikroTik's specific API.
  • RADIUS (parallel prototype): the router delegates AAA to Navigator over a standard protocol. More portable across vendors, but requires implementing AAA semantics correctly — and nothing in the live client/API path talks to it yet.

Keeping this as an isolated, disconnected service rather than forcing it into the same data model as the live path was intentional — it let the team evaluate the RADIUS approach without risking the working prototype.

What's scaffolded vs. what runs

Not every repo in the system is equally mature, and that's tracked deliberately rather than left ambiguous. navigator-enterprise-api has helmet, cors, compression, and error handling wired into server.js, plus dependencies signaling the intended design — jsonwebtoken and argon2 for real session auth, zod for schema validation, envalid for typed env config — but the actual route files are empty. navigator-enterprise-client doesn't exist on disk yet. Being explicit about which pieces are "working prototype" versus "scaffold, not wired up" is what makes a five-repo system reviewable instead of a pile of half-finished code that all looks equally done from the outside.

Takeaway

Navigator is a small case study in matching architecture to what the hardware already guarantees: rather than reimplementing hotspot enforcement, it builds a thin, phone-number-authenticated control plane on top of RouterOS's existing hotspot engine, and evaluates a second, protocol-standard path (RADIUS) in parallel without letting it destabilize the first. The password-encryption decision is the clearest signal of the engineering approach our team brings to this kind of work — solve the actual constraint (the router needs the plaintext back), document why it deviates from the "obvious" secure default, and call out the resulting risk explicitly rather than leaving it for someone else to discover.