| title | LinkSnatch: Prenegotiated embed tokens are unsafe at admin scope |
|---|---|
| description | How and why Stripe-style iframe security breaks down for admin embeds. Live demos within. |
| date | 2026-03-09 |
| highlight | true |
import { VulnerableDemoWithCode } from "../../app/routes/demos/linksnatch/-VulnerableDemo"; import { RaceDemoWithCode } from "../../app/routes/demos/linksnatch/-RaceDemo"; import { SecureDemoWithCode } from "../../app/routes/demos/linksnatch/-SecureDemo"; import { TokenDemo } from "../../app/routes/demos/linksnatch/-TokenDemo"; import { Callout } from "../../app/components/Callout";
There's a pattern I see everywhere—popularized by Stripe—for passing session-like state to an iframe: your server gets a token, passes it to the client, and the client opens the iframe.
Stripe's Payment Intent tokens pre-negotiate a vendor (where the money goes), the amount (how much money), and—if Stripe is responsible for it—the shipping address (for, y'know, taxes and stuff). If someone were to exfiltrate the token, it's fine; the blast radius is bounded by design. The attacker gets the ability to pay your invoice for you. Thanks!
<img src="/stripe-flow.svg" alt="Sequence diagram: Your Server creates a PaymentIntent via Stripe API, receives token pi_abc123, passes it to the Browser iframe, which confirms payment to Stripe" style={{width: '100%', maxWidth: '600px', margin: '1.5rem auto', display: 'block'}} />
Now imagine the iframe isn't a payment form. It's a full payroll admin panel—salaries, tax withholdings, social insurance numbers—embedded inside a partner's app. The token doesn't scope to a provably-safe action; instead it's a skeleton key to read and write every employee's compensation data. A single XSS on the host page, a single compromised browser extension, and that token is sitting, vulnerable in the DOM.
I built exactly this kind of embed at a fintech startup. When I realized the inherited security model was quietly broken, I designed a fix. We used the same disclosure repro internally to validate the alternative architecture. Within days of building the proof of concept, I disclosed the class of vulnerability to two competing vendors. One claimed single-use tokens; both classified it as informational.
And now, two years later still un-mitigated, I present LinkSnatch: the problem, the fix, and a sprinkling of interactive demos for you to play with the exploit, then defend against it.
Among the many embeds SaaS products have at their disposal, like analytics and ad networks and ID verification, there is a growing category of "embedded payroll" providers. I know, because I was a Founding Engineer at one.
Embedded payroll customers offer payroll in their own apps. Without naming customers, this product fits well with point-of-sale systems, employee scheduling systems, business banking portals, etc. To simplify and speed up the go-to-market motion, embedded payroll providers often offer embeddable payroll components, so partners can start running payrolls as soon as reasonably possible.
But admin tokens aren't scoped to a single action. They're scoped to everything the admin page can do. Change salaries, payout bank accounts. Modify tax withholdings. Access employee SSNs. The token proves the page was authorized to load the admin view, and that's the end of the security story.
The host page has other JavaScript running. Analytics. Chat widgets. Error tracking. A compromised npm dependency. A rogue browser extension. Any third-party script — or any script compromised via a supply chain attack — shares the same execution context. It can read the DOM. It can intercept network requests. It can find your admin token.
That's it. A few lines of JavaScript that any script on the page could run. The token is in the URL, in the DOM, in the network request — pick your exfiltration vector. Once an attacker has it, they have admin access to your customers' payroll.
Payroll is the example here, but the vulnerability applies to any embedded admin component where the token grants broad access: HR dashboards, financial controls, customer data panels, etc. If you embed it and the token can perform privileged actions, you're exposed.
I call this class of vulnerability LinkSnatch: where privileged credentials intended for a server-to-iframe channel are exposed to arbitrary JavaScript on the host page; exploitable via XSS, supply chain compromise, or any script sharing the host page's execution context.
- The
sandboxattribute on iframes can restrict iframe capabilities, but the threat is in the host page. A cleverMutationObservercan instantly grab this from the iframe'ssrcattribute, and the attacker is off to the race condition before the iframe even loads. - Content Security Policy could limit exfiltration vectors, but that assumes every embedding partner will maintain a strict CSP, and it won't help if one of their trusted scripts gets compromised.
- Single-use tokens make things safer but cannot guarantee safety; more on this below.
The attack surface is the host page, and while the host page is the embedder's responsibility, the boundary is the vendor's to design and enforce.
One vendor I disclosed this to claimed their tokens were single-use. They did not provide a test environment to verify this. But even if true, single-use doesn't help.
The race is between your iframe and the attacker's script. Both execute in the same browser, on the same page, at the same time. The attacker's script can intercept the token before the iframe uses it — or use it in a parallel request faster than the iframe's own initialization.
A correctly implemented single-use token with server-side atomic consumption does make this harder; the attacker needs to win a genuine network race rather than simply reading a value. But it can only move the vulnerability into probabilistic space, where attackers race the frame provider's p90, p99 response times to gain an edge. Because the attacker's script only needs to win once per payroll account, and can retry on every page load.
The fundamental problem remains: you're sending a credential to an environment you don't control and hoping nothing else reads it first.
The fix is to stop trusting the client entirely. Don't send tokens to the browser. Don't assume the iframe's environment is safe. Instead, like Charlie Munger says, "invert, always invert." Make the iframe prove every request is authorized by getting the embedder's server to co-sign it.
Here's the architecture:
The RequestRequest flow: every API call is signed server-side, so the iframe never holds a credential.The iframe routes every request through the host page, because that's where auth context lives (e.g. the user's session, role-based access, the embedder's own security model...). The iframe never holds a persistent credential. Every operation requires a fresh signature from a server the attacker can't reach.
// The embedder's signing endpoint — this is all they need to implement
import { SignJWT } from 'jose'; // There's a JWT library in every language
type RequestRequest = {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
resource: string; // URI path, e.g. "/api/employees"
bodyHash?: string; // SHA-256 of the request body (helps secure POST/PUT/PATCH)
};
// Called from a POST endpoint protected by same-origin session cookie + CSRF token
async function signRequest(request: RequestRequest, sessionToken: string) {
// Check your own auth — session, RBAC, whatever you already use
const user = await validateSession(sessionToken);
if (!user || !user.canAccess(request.resource)) {
throw new Error('Unauthorized');
}
// Sign the request — 30s expiry, one operation, one user
return await new SignJWT({ ...request, userId: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('30s')
// EMBEDDER_SECRET: your auth primitive. HMAC shared secret, RSA/EC keypair,
// OAuth2 client credentials — whatever fits your infra. See lucia-auth.com.
.sign(EMBEDDER_SECRET);
}The type signature is deliberate. By binding the JWT to a specific method, resource, and (when appropriate) body hash, a stolen signature is useless for anything other than the exact operation the user was already performing in an already-authorized frame. An attacker who intercepts a GET /api/employees signature can't replay it as POST /api/employees to create a ghost employee; the method doesn't match.
Each layer compounds the difficulty. Spoofing a write requires reverse-engineering the body hash. Submitting the forged request requires matching the origin, CSRF token, and session metadata the signing endpoint expects. An attacker who can do all of that has already compromised far more than an iframe — at that point, the embed token is the least of your problems.
<Callout variant="tip" title={<>On postmessage protocols</>}>
If you're implementing something like this, rimless is my favourite cross-context postMessage wrapper. It handles the handshake, origin validation, and message typing so you don't have to.
If you roll your own postMessage API, two notes:
- To avoid dropped messages during initialization, the iframe should wait for a
readysignal from the host before initiating requests.rimlessuses a handshake where the host page is expected to attach its listener early; guest frames then send a handshake init to their parent and await the response. - Please, for security's sake, validate
event.originon every message. Skipping this check is one of the most common iframe security mistakes. Friends don't let friends handle messages from untrusted origins!
This design also gives you revocation for free: given that an iframe's session may outlast the host application's, for example when the user opens the page, starts some work, joins a meeting, and comes back hours later. With prenegotiated tokens, that stale session still has full access. With RequestRequests, the host page's auth has to re-validate every call. If the session expired, the signing endpoint rejects it.
If the signing endpoint is slow or unavailable, the iframe should degrade gracefully — show a retry prompt or a clear error state, not a frozen UI.
Whether the session is four seconds or four hours, every payroll action gets authorized through the embedder's signing endpoint. Yes, this is a new architecture for the vendor's SDK. That's the point. The old architecture is broken. This does require vendor cooperation — embedding partners can't implement RequestRequest unilaterally. But the integration burden is minimal: one signing endpoint, behind auth you already have.
Although the round-trip adds latency per request, in practice it was negligible. If each round-trip is at most 100ms, five parallel reads take at most ~200ms total, not a whole second. Batch where you can, parallelize the rest, and pre-warm when possible. You've got this.
Same page. Same rogue script. Same exfiltration attempt. Nothing to steal.
I disclosed this to two competing vendors whose embedded admin components were vulnerable on January 26, 2024 — one YC-backed, the other backed by Stripe. I gave both a 90-day timeline.
One responded quickly, suggested they use single-use tokens, and classified the report as informational. The other missed self-imposed SLAs and required significant follow-up, also eventually classifying it as informational.
As of publication (March 2026), both vendors' documentation still describes the vulnerable pattern. Neither has changed their embed architecture in over two years. In that time, these platforms have processed hundreds of millions—possibly billions—of dollars in payroll on behalf of thousands of companies, every one of which is exposed to this exploit by a single malicious script or supply chain compromise on the host page.
I'm not naming either vendor here. Both are aware this post is being published and have had two years since initial disclosure to act. A determined reader could ascertain this without much vigor.
This isn't a story about two specific companies though. It's about a pattern that an entire category of B2B embed products copied from a context where it was safe, and applied it to a context where it isn't.
The Stripe pattern is brilliant. For Stripe's use case. The mistake is treating "Stripe does it this way" as a universal security architecture.
Why don't vendors fix this? Because the incentives are misaligned. Migrating to a signing-based architecture means shipping a breaking SDK change, coordinating every embedding partner to update their integration, and absorbing the support burden of a major migration.
Meanwhile, the risk of not fixing it falls silently and entirely on their embedding partners and the payroll accounts jointly administered. Classic negative externality.
These are platforms handling employee SSNs, salary data, and tax withholdings. Two years of inaction after disclosure is a gap that penetration testers and SOC 2 auditors may consider a flag, a potential exposure under GDPR and applicable regional privacy law, and in a post-breach scenario, evidence of known-but-unaddressed risk.
If you're a vendor building embeddable admin UIs: Stop sending tokens to the browser. Ship a postMessage-based signing flow. Your customers' host pages are not your security boundary. Assume they are hostile.
If you embed meaningfully-privileged UX from a third-party: Ask your vendor: "Can you show me your threat model for what happens when a third-party script on our page reads the embed token?" If they can't answer that question, you know where you stand. In the meantime, audit your third-party scripts, tighten your CSP (MDN CSP guide), and review your auth primitives (Lucia Auth is a solid starting point). It's not a fix, but it shrinks the window.
If your company's payroll runs through an embedded admin component: Ask your payroll provider whether their embed integration uses client-side tokens or server-to-server signing. If it's the former, every time you load your payroll app, you are at risk of token exfiltration, letting an attacker perform the same actions you're about to.
The interactive demos on this page are built with TanStack Start server functions and a Cloudflare Durable Object for atomic token consumption. Source code available as a gist.