Skip to content

Security model

This page explains the trust model behind the Expedition Insure embed: why the publishable key is safe to ship in your HTML, and what enforces security instead of key secrecy. It is for the engineers and security reviewers who sign off on dropping our widget into your site.

If you want the step-by-step install, read the integration guide first — its event reference covers every payload your page receives. For how sales are attributed to you, see reconciliation and attribution, and for what data crosses the boundary, see data and privacy. This page is the “why is this safe” companion to all three.

The short version: the publishable key (pk_op_...) is public by design. The trust boundary is enforced server-side by a per-origin allowlist and a per-key rate limit, and in the browser by fail-closed iframe framing and an origin-locked postMessage channel. No single one of these is the whole defense — they are layered, and each fails closed.

1. The publishable key is public by design

Section titled “1. The publishable key is public by design”

You embed the widget with a publishable key:

<script src="https://expedition.insure/widget.js"></script>
<script>
ExpeditionInsure.mount("#quote", {
pk: "pk_op_...", // ships in your HTML — not a secret
destination: "antarctica",
tripCost: 18000, // whole dollars
});
</script>

pk_op_... is meant to live in your page’s HTML, exactly like a Stripe publishable key. It is not a secret, and treating it as one buys you nothing — anyone who can load your page can read it.

So the security model deliberately does not rest on key secrecy. A copied key is useless from anywhere we haven’t approved, because every embed request is gated by two server-side controls (sections 2 and 3) and the iframe only renders for origins we’ve allowlisted (sections 4 and 5).

Why public keys. A browser-side widget can’t hold a secret — there’s nowhere to hide it. Rather than pretend otherwise, we bind trust to where the key is used (your allowlisted origins) instead of who holds it. This is the same trade-off Stripe makes with pk_ keys.

There is a separate secret-key format (sk_op_...) reserved for future server-to-server use. No HTTP endpoint consumes it today — there is no server-to-server embed API yet, so sk_op_... currently gates nothing. Do not build against it. [needs-product-work]

When we provision your key, you give us the exact origins your widget will run on (for example https://www.youroperator.com, plus any staging origin). These become your per-operator origin allowlist.

  • Origins are scheme + host (+ optional port) only — no path, query, or fragment. https://www.youroperator.com is an origin; https://www.youroperator.com/quote is not.
  • HTTPS is required (only localhost / 127.0.0.1 may use http, for local development).
  • You can register up to 20 origins per operator. Tell us promptly when your set of origins changes so we can update the list.

Every embed API request carries the browser’s Origin header. The API matches it against your allowlist:

OutcomeWhenResponse
AllowedOrigin is on your allowlist200 with the matched origin echoed in Access-Control-Allow-Origin
BlockedOrigin missing, or not on your allowlistbare 403 "Forbidden"no CORS headers

The block is deliberately blunt. A 403 with no CORS headers means the browser refuses to expose the response body to the page, even though a body string is present. A request from a non-allowlisted origin can never read any data.

Why no CORS headers on failures. CORS is the browser’s gate, not the server’s. By omitting Access-Control-Allow-Origin on 401/403 responses, we let the browser block the body for us — a stolen key fired from an unapproved origin gets a response it can never read.

CORS on the success path never reflects *. Only the single matched origin from your allowlist is echoed, always with Vary: Origin so a shared cache can’t serve one origin’s headers to another. Credentials are off — the key is the auth, never cookies — so no Access-Control-Allow-Credentials is ever sent.

The browser sends an OPTIONS preflight before the real request. Preflight reflects the requested Origin and returns 204 without requiring the key (browsers never attach custom headers to a preflight). That’s safe: the real POST/GET re-runs the full gate. A non-allowlisted origin can pass preflight but still gets a bare 403 with no CORS on the data-carrying request — so it never reads a body.

3. Rate limit — 60 requests per minute per key

Section titled “3. Rate limit — 60 requests per minute per key”

Every embed request is rate-limited at 60 requests per minute, per publishable key (a fixed one-minute window). Exceed it and the API returns:

429 Too Many Requests
Retry-After: <seconds>

Unlike the 401/403 responses, the 429 does include CORS headers and a Retry-After, so your code can read it and back off gracefully.

Why rate-limit a public key. Because the key is public, anyone can copy it. The allowlist already stops them from reading data off a non-allowlisted origin, but the rate limit is the second wall: it caps abuse of the key even from approved origins, and bounds the blast radius of a misbehaving integration. Respect the Retry-After header and don’t hammer the options poll — poll only while the response says pending.

The widget renders inside an iframe served from our origin. Whether that iframe is allowed to appear in your page is controlled by a Content-Security-Policy: frame-ancestors header that we set on every embed page response, resolved from your key:

  • Origin on your allowlist → frame-ancestors lists your origin → the iframe renders.
  • Origin not on your allowlist → frame-ancestors 'none' → the browser refuses to render the iframe.

This is fail-closed: the default state of an unknown or unapproved origin is a blank frame, never a rendered one carrying data. There is no “open by default” mode to misconfigure.

Why fail-closed. A fail-open default would mean any site that copied your key could frame our widget until someone noticed and shut it off. Fail-closed inverts that: nothing renders unless we’ve explicitly approved the framing origin, so a leaked key can’t be quietly framed somewhere you don’t control.

For the iframe to render, allow our origin in your own CSP as both a script source and a frame source:

script-src 'self' https://expedition.insure;
frame-src https://expedition.insure;

You do not need to relax connect-src — the API calls happen from inside our iframe, on our origin. If the iframe stays blank, check the browser console for a frame-ancestors violation; that means your origin isn’t on the allowlist yet.

We set this sandbox attribute on the iframe (you don’t configure it — it’s listed so your security review knows what to expect):

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation"
TokenWhy
allow-scriptsRuns the quote app and Stripe.js inside the iframe.
allow-same-originThe framed document is our origin, so same-origin is safe — and Stripe.js requires it. It is never your origin.
allow-formsThe quote and checkout forms.
allow-popupsProvider and 3-D Secure flows that open a tab.
allow-top-navigation-by-user-activationLets the Buy → hosted-checkout handoff navigate the top window only on a user click. The iframe can never silently redirect your page.

That last token is scoped to user activation on purpose: the iframe can hand off to checkout when the customer clicks Buy, but it cannot navigate your page on its own. If your own outer sandbox strips the token, the loader falls back to an origin-validated ei:navigate message that only ever navigates to a URL on our embed origin (section 5).

5. Origin-locked postMessage, in both directions

Section titled “5. Origin-locked postMessage, in both directions”

The widget and our iframe talk over a namespaced ei:* postMessage channel. Origins are locked in both directions — "*" is never used as a trusted inbound value, and never as an outbound target for quote or payment data.

When your page receives a message from the iframe, the loader drops it unless all of these hold:

  1. event.origin exactly equals our embed origin (never "*").
  2. event.source is the contentWindow of an iframe this loader actually mounted.
  3. event.data is an object with a string type.
  4. That type starts with ei:.

Only then does the loader dispatch the corresponding public event to your .on() handlers. Anything else is silently ignored.

Why lock inbound. Any frame on the page can call postMessage at your window. Without an origin check, a malicious frame could forge a quote.selected or payment.succeeded and trick your checkout logic. Pinning event.origin to our exact origin — and matching the source window — means only our iframe can drive your handlers.

When the iframe sends quote or payment data to your page, it targets the exact origin of your page (resolved from the host parameter the loader passes in), never "*". If it can’t resolve a concrete origin, it drops the message rather than broadcast it.

Why lock outbound. postMessage(msg, "*") delivers to whatever frame happens to be the parent — including a malicious page that framed your page. Quote and payment payloads carry planId, premiumCents, and quoteId; broadcasting them to "*" would leak that to any enclosing frame. Refusing to send unless we know the exact target origin closes that leak.

These two checks are independent and overlapping by design: the iframe targets your exact origin outbound, and the loader independently re-validates our exact origin inbound. Defense in depth — a gap in one is still caught by the other.

The ei: prefix is stripped before your handler sees the event, and a throw in your handler is caught and logged so it can never break the bridge or stop other listeners. The full event payloads are in the integration guide.

6. Money fields and the authoritative signal

Section titled “6. Money fields and the authoritative signal”

Two contract details that intersect security and correctness — read them before you wire money or fulfillment off an embed event:

  • Read premiumCents. Quote and payment events carry premiumCents (an integer in minor units). Read premiumCents and divide by 100 yourself for display.
  • payment.succeeded is a UX signal only. It fires from inside the iframe to let you sequence your own next step, but it can be missed (a closed tab, a dropped connection). It is not the authoritative record of a sale. The authoritative post-sale signal is the policy.issued webhook, which we send server-side off the payment event. Reconcile and fulfill against the webhook, never against the browser event. See reconciliation and attribution.

7. What each layer defends — at a glance

Section titled “7. What each layer defends — at a glance”
Threat Control that stops it
------------------------------ ----------------------------------------
Copied key used from a Per-origin allowlist (section 2) —
non-allowlisted origin bare 403, no CORS, browser blocks body
Copied key framed on a Fail-closed frame-ancestors (section 4) —
rogue site resolves to 'none', iframe won't render
Key abuse / request flooding Rate limit (section 3) — 60/min/key, 429
Forged inbound postMessage Origin + source lock (section 5) —
only our exact origin + our iframe trusted
Payload leak to a parent Outbound origin lock (section 5) —
frame targets your exact origin, never "*"
Silent top-window redirect User-activation-scoped sandbox (section 4) +
origin-validated ei:navigate fallback
Fake "sale" from the browser policy.issued webhook (section 6) is the
source of truth, not payment.succeeded

No layer is load-bearing alone. The key being public is fine, because the allowlist, rate limit, fail-closed framing, and origin-locked messaging each fail closed on their own.

If you find a security issue with the embed, or your set of origins changes, email help@expedition.insure. We’ll update your allowlist or investigate promptly.