intermediate
city-portal
magic-link
HMAC

City Portal & Magic-Link Auth

The /city/{slug} portal that city contacts log into via emailed magic link — bounded by the jurisdiction's geometry, read-only, with a 24h HMAC-signed session cookie.

Levy Fleets TeamMay 18, 202610 min read

City Portal & Magic-Link Auth

City compliance officers don't want another password. The Levy city portal at /city/{slug} uses email-based magic-link auth — enter your email, click a link, you're in. The session is HMAC-signed, lasts 24 hours, and is scoped to a single jurisdiction.

Who can log in

Only emails listed in city_contacts with portal_access = true can authenticate. Adding a city contact happens in the operator dashboard, not the portal itself — there is no self-service signup.

URL structure

PageURLPurpose
Login/city/{slug}Enter email, request magic link
Auth callback/api/city/{slug}/auth/callback?token=<tok>Consume the magic link, set session cookie, redirect to dashboard
Dashboard/city/{slug} (post-auth)Compliance scoreboard + links to other views
Fleet map/city/{slug}/fleetVehicles inside the jurisdiction's bbox
Trip heatmap/city/{slug}/tripsTime-bucketed trip density
Parking corrals/city/{slug}/parking-corralsUtilization snapshots
Compliance report/city/{slug}/compliance-report?period=monthly&date=2026-05Download CSV/PDF

The slug matches mds_jurisdictions.slug. One slug = one jurisdiction = one bbox.

1

Contact enters their email

On /city/{slug}, the form posts to POST /api/city/{slug}/auth/magic-link with { email }. The route always returns HTTP 200 — we don't reveal whether the email exists in city_contacts (avoids enumeration).

2

Token minted and emailed

If the email matches a city_contacts row with portal_access = true for this slug, the route generates a one-time token, stores its sha256 hash + expires_at = now() + 15 minutes on the contact row, and emails the link.

3

Contact clicks the link

The link goes to /api/city/{slug}/auth/callback?token=<tok>. The route hashes the token, looks up the matching city_contacts row, checks expires_at, marks the token consumed, and issues a session cookie.

4

Session cookie set

Set-Cookie: levy_city_session=<hmac>; Path=/city/<slug>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400. The cookie payload encodes city_contact_id, jurisdiction_id, issued_at. HMAC-signed with a server-side secret.

5

Redirect to /city/{slug}

Now showing the post-auth dashboard. Every subsequent request to /api/city/{slug}/* verifies the HMAC and bbox-clips the response.

What the contact sees

The dashboard shows:

  • Compliance scoreboard — pass/fail per permit_conditions row, 30-day rolling
  • Today's snapshot — deployed fleet count, trips, open complaints
  • Active policies — the rules ingested from their own Policy feed (sanity check)
  • Links — to fleet map, trip heatmap, parking corrals, monthly report

Everything below the snapshot is read-only. The portal does not let the city edit operator data — they edit their own policy feed, and we re-ingest it.

Bbox clipping

Every city-portal API route is clipped to the jurisdiction's bbox before returning data. The clipping is enforced server-side, not at the UI layer:

// Inside every /api/city/{slug}/* route
const session = await verifyCitySession(request);
const jurisdiction = await getJurisdiction(session.jurisdiction_id);
const bbox = jurisdiction.bbox; // [minLng, minLat, maxLng, maxLat]

const vehicles = await sb
  .from('vehicles')
  .select('id, last_lat, last_lng, status, vehicle_number')
  .gte('last_lng', bbox[0])
  .lte('last_lng', bbox[2])
  .gte('last_lat', bbox[1])
  .lte('last_lat', bbox[3]);

A city contact will never see a vehicle outside their boundary, even if it exists in the same subaccount.

For the trip heatmap, clipping is performed on the trip's start point — a trip that began inside the jurisdiction and ended outside still appears on the heatmap. This is what cities expect for trip-origination analytics.

Session expiry + refresh

Sessions last 24 hours. There is no refresh flow — the contact requests a new magic link when the session lapses. This is deliberate: a city analyst losing access for a few hours and going through the magic-link motion again is acceptable; long-lived city portal sessions are not.

The HMAC secret can be rotated by setting a new value in environment config and redeploying. Sessions signed with the old secret immediately fail HMAC verification and the contact gets a clean re-login prompt.

Multiple contacts per jurisdiction

A jurisdiction can have any number of city_contacts rows. Common pattern:

RoleEmail
Lead compliance officersarah@city.gov
Permit reviewerpat@city.gov
Populus / Ride Report analyst (consultant)analyst@populus.ai

All three see the same data, all three receive digest emails on their own cadence. Sessions are independent — one contact logging out does not affect the others.

Revoking access

To revoke a contact's access:

  1. Open /dashboard/compliance/{jurisdiction-id} -> City contacts.
  2. Set portal_access = false on the row, or delete it outright.
  3. Active session cookies on that contact will fail server-side verification at the next request — the cookie is still valid HMAC, but the contact row no longer permits portal access.

Magic links already minted to that contact are also invalidated — auth/callback rejects tokens for contacts where portal_access = false.

Localization

The portal is rendered in the contact's language preference (city_contacts.locale, defaults to en). Digest emails also use the same preference. Launch supports en and es; additional locales fall back to English.

Troubleshooting

  • Contact says "didn't get the email" — check spam, then confirm the email matches city_contacts.email exactly (case-insensitive). Re-send by entering the email in the portal form again.
  • Link says "expired" — magic links live 15 minutes. The contact should request a new one.
  • Link says "invalid" — the token was already consumed, the contact row was deleted, or portal_access was set to false. Investigate in the operator dashboard.
  • Session cookie not setting — the contact is on a browser that blocks third-party cookies and /city/{slug} is being loaded inside an iframe somewhere. Avoid iframe embeds.

What's next