Skip to main content

Authentication

Guests sign in to an Edge Portal with a magic link — a one-time sign-in link addressed to the email on their booking. There are no portal passwords to create, forget, or reset. A verified link is exchanged for a short-lived portal session token, which authenticates every data call the portal UI makes.

Guest enters email → POST /auth/magic-link → 202 accepted (always)
Guest opens link → POST /auth/magic-link/verify → 201 { token, session }
Portal UI → data calls with the session token

Issuance. The portal UI submits only tenantId, portalId, and the guest's email. The traveler identity behind the link — subject, role, display name — is resolved server-side by the portal's data provider; an unauthenticated caller can never choose who a link signs in as. Identity resolution happens only for portals of type guest: an agent or operations portal never resolves a traveler, even for the same email in the same tenant.

No account enumeration. A known email and an unknown email get the same 202 { "status": "accepted" } response. Whether a matching traveler exists is never observable from the issuance endpoint.

The link token is a 256-bit opaque value with a short lifetime — 15 minutes by default, configurable per deployment up to a hard cap of one hour. Only its SHA-256 hash is ever stored or logged; the raw token exists only in the delivered link.

Verification is strictly single-use. The first POST /auth/magic-link/verify with a valid token consumes it atomically and returns a session token; any replay fails with a stable error code:

CodeMeaning
INVALID_TOKENThe token was never issued (or has been swept after expiry)
EXPIREDIssued, but past its lifetime
ALREADY_USEDIssued and already consumed once

Consumed links are retained until their natural expiry so a replayed token reliably reports ALREADY_USED rather than decaying into INVALID_TOKEN.

Token storage

Single-use link state lives behind a storage seam with two implementations:

  • In-memory (default without a database) — single-use holds per process instance only. Fine for local development and tests.
  • Postgres (magic_link_tokens table) — selected automatically when a database is configured, and the precondition for running live portals. Consumption is an atomic conditional update, so multiple portal replicas racing the same token yield exactly one winner, across restarts.

When Postgres is selected but unreachable, issuance and verification fail loudly (5xx) rather than silently falling back to memory, which would defeat the single-use guarantee across replicas.

Email delivery of the link is handled upstream of the portal service. In development the issuance response echoes the raw token so local integration flows complete without email delivery; outside development the response only acknowledges the request.

Portal session tokens

A verified link mints a signed, tenant-scoped session token:

PropertyValue
FormatJWT signed with the tenant's secret (the platform-wide tenant JWT model)
Audienceedge-portal — tokens minted for other Edge products do not validate here
ClaimstenantId, portalId, sub (traveler subject), role (default traveler), and optional email / firstName / lastName
Lifetime2 hours
TransportAuthorization: Bearer <token> or the x-portal-session-token header

Every portal-scoped data route validates the token and additionally checks that its tenantId and portalId claims match the request path — a session for one portal cannot read another portal's data, even within the same tenant. Data reads are further scoped to the authenticated traveler's own bookings; see Integration architecture.

POST /api/portal/v1/session/validate lets a portal UI check a stored token (for example on page load) and recover the session claims without making a data call.

Development session mint

POST /api/portal/v1/dev/session-token mints a session directly from caller-supplied claims — a local-development shortcut so UI work doesn't need the full magic-link loop. The endpoint only exists when the service runs in development; it can be forced off there, and can never be enabled in staging or production, where it returns 404.

Embedding and framing

Portal pages are standalone, first-party pages. The service sends X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' on its responses, so a portal cannot be embedded in an iframe on another site. Link to the portal from your website and emails rather than framing it.

Demo portals versus production

Two different things are sometimes both called a "demo", and their authentication posture differs:

  • A mock-provider portal (provider: mock in portal.yaml) is a real portal in every respect — magic-link sign-in, session tokens, and per-traveler data scoping all apply — it just serves seeded fixture data instead of live systems. Use this to evaluate the full product flow.
  • A tenant UI with embedded simulated data — a portal whose UI source ships its demo data inside the JavaScript bundle and makes no /api/portal/v1 calls. Since no data route is ever hit, no session is required and no sign-in is enforced: the page renders for anyone who has the URL. That is a property of what the tenant UI chose to build, not a platform authentication mode — the moment the same UI fetches from the API, every call requires a valid session.

In production, every trip, payment, document, form, and extras read goes through a session-authenticated API call — there is no unauthenticated data path.