Build an embeddable account-linking / authorization widget Β· RADIO framework Β· Deep dives: security, OAuth, idempotency
Requirements (0β8 min): clarify auth model (OAuth vs legacy), devices, a11y, resilience; state non-goals: SEO, load perf.
Architecture (8β18 min): draw iframe + SDK + postMessage bridge, and draw the trust boundary out loud.
Data model (18β26 min): LinkSession record, FlowState enum, the three tokens; say which values the client may hold.
Interfaces (26β36 min): SDK surface, postMessage events, REST endpoints; mention Idempotency-Key β a client-generated UUID header on every mutating POST, server executes once and stores the response, duplicates get the stored response so double-clicks/retries collapse to one effect.
Optimizations (36β58 min): spend your remaining time here β OAuth redirect + rehydration β idempotency β postMessage/CSRF security β edge cases.
link_token in β public_token out β access_token never touches the browser.postMessage('*'). Always check event.origin with exact match against a server-derived allowlist.Plaid Link is a client-side widget that a customer (an app that wants bank data) embeds. The end user's bank credentials never touch the customer's page or the customer's servers. That credential isolation is the entire product.
Rendering choice: a single-page app rendered inside an iframe modal, not a multi-page app. MPA page reloads would destroy in-memory flow state and look broken inside a modal. The one twist: the OAuth step forces a real full-page redirect out to the bank and back, so the SPA must rehydrate cleanly after a redirect. That survival requirement is the same thing as idempotency and is the heart of the problem.
Non-goals (state deliberately): SEO does not matter (widget is not indexed content), and initial load perf is not a focus (small surface). Calling these out shows deliberate scoping.
The questions above resolve to the committed scope below. State this out loud β "so I'm building this, not that" β before moving to architecture.
public_token on success.CONSENT β SELECT_INSTITUTION β OAUTH_REDIRECT β OAUTH_RETURN β ACCOUNT_SELECT β SUCCESS, and clean rehydration after the bank redirect.create / open and injects an iframe pointing at the Widget SPA, served from our own origin. The iframe is what enforces isolation β the customer's page cannot read inside it.link_token and later exchanges the public_token. It does not talk to the widget directly β it talks to our backend.Diagram A β Architecture & trust boundaries
flowchart TB
subgraph Browser["User Browser (one tab)"]
Host["Host Page<br/>app.customer.com"]
SDK["JS SDK loader"]
subgraph Iframe["iframe β Widget SPA<br/>link.provider.com"]
Widget["Widget UI + FlowState"]
end
Host -->|includes| SDK
SDK -->|injects| Iframe
Iframe <-->|"postMessage<br/>(origin-checked)"| Host
end
OurBackend["Provider (Plaid) Backend"]
CustBackend["Customer Backend"]
Bank["Bank OAuth Server"]
Widget -->|"HTTPS / fetch"| OurBackend
Host -->|"public_token via onSuccess"| CustBackend
CustBackend -->|"server-to-server exchange"| OurBackend
OurBackend <-->|"OAuth"| Bank
Widget -.->|"full-page redirect"| Bank
classDef secret fill:#ffe0e0,stroke:#c00;
classDef safe fill:#e0f0ff,stroke:#06c;
class OurBackend,CustBackend,Bank secret;
class Host,SDK,Widget safe;
Each LinkSession is a row the server owns. This is the object that must survive the OAuth redirect. The client never holds this record β it holds only the link_token breadcrumb that resolves to it.
| LinkSession field | What it is | Exposed to |
|---|---|---|
session_id | Internal primary key for this session record. Not a credential. Lifetime β 4 hours (whole flow). Ideally never rides in a bank redirect URL. | Server-internal (client uses link_token as its handle) |
link_token | Client-facing bearer credential with expiry, minted by customer backend using secret creds. The client's handle for the session. | Browser (safe-to-leak: short-lived, scoped) |
status / current_step | Authoritative FlowState. The server is the source of truth for the current step. Client renders it, never asserts it. | Reported to client via GET |
institution_id | The bank chosen in SELECT_INSTITUTION. | Client selects, server persists |
| oauth_state_id | Server-generated single-use nonce for the current OAuth leg. Generated at the CONSENTβOAUTH_REDIRECT transition. Overwritten with a fresh value on retry (old value auto-invalidated by the "must match current" rule). Burned on use. | Bank + browser URL (safe: single-use, unguessable) |
selected_accounts | Account IDs the user kept in ACCOUNT_SELECT, persisted server-side. Final set = intersection of what the bank granted and what the user selected. | Client proposes via POST, server persists |
created_at / expires_at | Session lifetime bounds (~4 hours). | Server-internal |
Corrected order: CONSENT comes first β the user consents to the data-sharing terms before picking a bank. There are two consent moments: (1) up-front consent to Plaid's data use, in-widget, and (2) authorization at the bank during OAuth, on the bank's page. Model the first as CONSENT; the second is part of the OAuth leg itself.
CONSENT β SELECT_INSTITUTION β OAUTH_REDIRECT β OAUTH_RETURN β ACCOUNT_SELECT β SUCCESS, plus ERROR. Belongs to the widget, mirrored on the server β the server copy is authoritative.
Diagram B β FlowState machine (corrected order)
stateDiagram-v2
[*] --> CONSENT
CONSENT --> SELECT_INSTITUTION: agrees to data-use terms
SELECT_INSTITUTION --> OAUTH_REDIRECT: bank chosen
OAUTH_REDIRECT --> OAUTH_RETURN: user authorizes at bank<br/>(bank-side consent + account grant)
OAUTH_RETURN --> ACCOUNT_SELECT: server confirms grant
ACCOUNT_SELECT --> SUCCESS: selection persisted,<br/>public_token issued
SUCCESS --> [*]
CONSENT --> ERROR
SELECT_INSTITUTION --> ERROR
OAUTH_REDIRECT --> ERROR: state mismatch / expired
OAUTH_RETURN --> ERROR
ACCOUNT_SELECT --> ERROR
ERROR --> [*]
note right of OAUTH_RETURN
SPA reloaded from scratch here.
Read oauth_state_id from URL,
link_token from sessionStorage,
then GET /link/session/:id.
Server reports the real step.
end note
id, name, logo, oauth_supported, products.link_token (init, short-lived), public_token (result, short-lived, exchanged server-side). access_token is server-only and does not belong in any frontend entity.Never let a long-lived secret touch the browser. Each token is weaker and shorter-lived the closer it gets to the browser, and every step that produces a stronger token happens on a server holding a secret.
| Token | Role | Lifetime | Where it lives |
|---|---|---|---|
link_token | Entry ticket β authorizes opening one Link session with a given config | Minutesβhours | Browser (safe: scoped, expiring) |
public_token | Throwaway receipt / claim check β can only be exchanged, grants no data access | ~30 min | Browser β customer backend |
access_token | The crown jewel β actually pulls balances and transactions | Long-lived | Server only. Never the browser. |
PlaidLink.create({ token: link_token, onSuccess, onExit, onEvent })handler.open() / handler.exit()onSuccess(public_token, metadata), onExit(error, metadata), onEvent(eventName, metadata)Every message validated against an origin allowlist, both directions. postMessage is browser-to-browser (two frames in one tab), not browser-to-backend.
| Direction | Messages |
|---|---|
| widget β host | READY, SUCCESS (carries public_token + metadata), EXIT, EVENT |
| host β widget | OPEN, CLOSE |
| Endpoint | Purpose | Read/Write |
|---|---|---|
POST /link/session | Creates the session from the link_token | Write (server assigns id β POST, not PUT) |
GET /link/session/:id | Lets the widget rehydrate after the redirect. Safe to repeat β survives refresh, back button, prefetch. No side effects, never burns the nonce. | Read |
POST /link/session/:id/institution | Selects an institution | Write |
POST /link/oauth/callback | Finalizes the OAuth return with {state, code} in the body. Not nested under :id β in the new-tab case the client doesn't know the session id, so the endpoint must be resolvable by state alone; the server looks up the session from the state. Validates state (CSRF), checks single-use (replay), exchanges the auth code with the bank, burns the state, advances the session, and returns the session handle + current step (so a new-tab client rehydrates from the response). The guarded write that fires exactly once. | Write |
POST /link/session/:id/accounts | Persists the user's account selection (re-submittable β overwrites the set) | Write |
POST /public-token/exchange | Customer backend exchanges public_token + secret creds for access_token. Server-to-server only. | Write |
Put an Idempotency-Key (client-generated per action) on the mutating POSTs so retries and double-clicks collapse to one effect. The server dedupes.
Diagram C β Token flow & the server-to-server exchange
sequenceDiagram
participant U as User Browser
participant W as Widget (iframe)
participant CB as Customer Backend
participant PB as Provider Backend
participant Bank as Bank
CB->>PB: create link_token (with secret creds)
PB-->>CB: link_token (short-lived)
CB-->>U: link_token
U->>W: open widget with link_token
W->>Bank: OAuth redirect (with oauth_state_id)
Bank-->>W: callback (echoes oauth_state_id)
W->>PB: finalize (oauth_state_id validated)
PB-->>W: session ok, public_token
W-->>U: onSuccess(public_token)
Note over U,CB: public_token sent up to customer backend
U->>CB: public_token
rect rgb(255,224,224)
CB->>PB: exchange public_token + secret creds (server-to-server)
PB-->>CB: access_token + item_id
end
Note over CB: access_token stored server-side,<br/>NEVER returned to browser
CB-->>U: "linked, ok"
When the user goes to the bank and comes back, the SPA reloads from scratch (amnesia). The problem on return: which session does this browser belong to? Two independent recovery paths, so one failing doesn't kill you:
link_token). Works when the redirect returns in the same tab.The client never validates the state β it is a courier, not a judge. A client-side "state I sent vs state I got back" check proves nothing (an attacker controls the client). The server generated the state, stored it in the LinkSession, and does the only comparison that counts. The client's jobs: before redirect, save the session handle and navigate to the bank URL the server built (state already inside); on return, read state + code from the URL and forward them to the server. Then GET /link/session/:id and render whatever current_step the server reports. Never trust a client flag that says "I was on step 4."
POST /oauth/callback with oauth_state_id + auth code. Server validates state (CSRF), checks single-use (replay), exchanges with the bank, burns the state, advances the session. The one write.GET /link/session/:id to read the now-current step and render. Safe to repeat.Why both? They answer different questions. The GET answers "where am I, what should I render" β read-only, refresh five times, nothing breaks. The POST answers "actually finalize this grant, exactly once" β a real state change with an outside effect (token exchange with the bank), guarded by the single-use nonce and the Idempotency-Key. Burning a nonce is a side effect, and side effects do not belong on a GET (a refresh or prefetch would blow up the session).
Diagram D β OAuth return: guarded POST vs safe GET
sequenceDiagram
participant Bank
participant U as User Browser (SPA)
participant PB as Link Server
Note over Bank,U: OAuth done, bank redirects back
Bank-->>U: redirect to redirect_uri?state=oauth_state_id&code=auth_code
Note over U: SPA reloaded, has amnesia.<br/>Read oauth_state_id from URL,<br/>session handle from sessionStorage
rect rgb(255,224,224)
Note over U,PB: WRITE β finalize exactly once
U->>PB: POST /link/oauth/callback<br/>(oauth_state_id, code, Idempotency-Key)
PB->>PB: state issued for live session? not yet used?
alt valid + unused
PB->>Bank: exchange auth_code (server-to-server)
Bank-->>PB: grant
PB->>PB: burn oauth_state_id, advance step
PB-->>U: ok, new step
else forged / replayed
PB-->>U: reject
end
end
rect rgb(224,240,255)
Note over U,PB: READ β safe to repeat on refresh
U->>PB: GET /link/session/:id
PB-->>U: current_step (authoritative) -> render
end
The UI shows a step only after the server confirms the transition. Do not render "success" until the token exchange / session finalize returns ok. Model it as a state machine where illegal transitions are rejected, so a stale message or duplicated redirect cannot jump forward.
link.plaid.com), host page on customer origin (app.customer.com). Same-origin policy blocks the frames from reading each other's JS/DOM β that block is what stops the customer's page from scraping credentials.'*' β targetWindow.postMessage(data, 'https://app.customer.com'). Receiving: check event.origin against the allowlist before trusting any message; drop silently otherwise. Origin allowlist trust chain: customer pre-registers domains at onboarding β that config gates link_token creation β the link_token seeds the per-session allowlist the iframe enforces. Origin = scheme + host + port; exact string match, never substring includes (naive includes('customer.com') passes evil-customer.com.attacker.com).Diagram E β Security surfaces (postMessage origin check + CSRF state)
sequenceDiagram
participant Evil as Malicious Page
participant Host as Host Page
participant W as Widget (iframe)
participant PB as Provider Backend
participant Bank as Bank
Note over Host,W: Surface 1 β postMessage channel
W->>Host: postMessage(SUCCESS, targetOrigin=app.customer.com)
Evil-->>W: postMessage(fake OPEN)
W->>W: check event.origin against allowlist
Note right of W: origin not allowed -> drop silently
Note over W,Bank: Surface 2 β OAuth redirect / CSRF
PB->>PB: generate random oauth_state_id (session-bound, single-use)
W->>Bank: authorize?state=oauth_state_id
Bank-->>W: callback?state=oauth_state_id
W->>PB: /oauth/callback (state)
PB->>PB: state issued for live session? not used yet?
alt valid + unused
PB-->>W: finalize ok (mark state used)
else forged / replayed
PB-->>W: reject
end
The client only ever holds values that are safe to leak. Anything whose exposure would break security stays server-side.
link_token (expires fast, scoped), oauth_state_id (single-use, useless once burned), public_token (inert without the secret).access_token, and the authority to validate or burn a nonce. Security nonces are generated server-side β a client-generated nonce is worthless for CSRF.| Case | Handling |
|---|---|
| Loading | Skeleton states in-widget; host shows nothing until the iframe posts READY. Institution list paginated/searched server-side. |
| Errors | Every FlowState has an edge to ERROR with a typed error (bank down, state mismatch, expired session). Widget renders a recoverable error screen; unrecoverable β EXIT with error metadata so the customer can react. |
| Retry | OAuth retry mints a fresh oauth_state_id on the same session (see Idempotency). Failed API calls retry with backoff + the same Idempotency-Key so retries collapse to one effect. |
| Cancel | User closes modal or ESC β widget posts EXIT (with step metadata), server marks session abandoned. Reopening requires a fresh or still-valid link_token. |
| Timeout | link_token and LinkSession carry expires_at (~4h). An expired session rejects all transitions; widget shows "session expired, restart." The nonce expires in minutes, independently. |
| Resume | The rehydration path IS the resume path: breadcrumb (sessionStorage + URL state param) β GET /link/session/:id β server reports authoritative step β render. Works after redirect, refresh, or crash within session lifetime. |
| Network retry & back button | Back button after return: GET is safe to re-fire; the POST finalize is protected by the burned nonce + Idempotency-Key, so back/forward cannot double-finalize. Offline mid-flow: queue the mutating call with its key, retry on reconnect; the server dedupes. |
| Double-click / double redirect | First callback wins and burns the state; the second is rejected or deduped to the same response. UI disables the action button until the server responds (no optimistic advance). |
link_token, per session, per IP, and per customer: cap session creations, institution searches, and OAuth attempts. A single session should never need more than a handful of finalize attempts β throttle and then kill the session.event.origin checks in the widget can be beaconed as telemetry.POST /link/session returns an id not known in advance. PUT is for when the client already knows the full URI and puts a complete resource there.oauth/callback kicks off a token exchange with the bank β an action, so POST reads more honestly.Subtlety: PUT is required by spec to be idempotent, POST is not β we add idempotency to POST manually via the key. select-institution could defensibly be a PUT (it just sets a field); be ready to justify action-style POST (side effects, consistency).
link_token = bearer credential with expiry ("token" implies expiry β correct instinct). What the client holds and passes into the widget at init: PlaidLink.create({ token: link_token }).session_id = internal primary key for the LinkSession record. Not a credential. In real Plaid the client only ever sees the link_token; it is the session handle from the client's point of view, resolved server-side to the record.It is the OAuth state parameter, specialized. One value, three guarantees:
Properties: unpredictable (random), tied to one session, single-use. Created by the server, always β at the transition into OAUTH_REDIRECT, right before sending the user to the bank. The client only carries it; it never mints or judges it. A client-generated nonce is worthless for CSRF.
In the exchange step, "client" means the customer's backend server (API client), not the browser. Two totally separate HTTP requests:
onSuccess(public_token); the frontend sends the public_token up to its own backend./item/public_token/exchange with public_token + secret creds. The response carrying access_token travels server-to-server; the browser is not a party to this request at all.What keeps the access_token off the client is not the network β it is the customer's backend deliberately not forwarding it. Echoing it to the frontend would be the exact bug the design exists to prevent.
Generalization: this is the standard OAuth authorization-code exchange. Browser returns a short-lived code; the app's backend exchanges code + client secret for an access_token, server-to-server. Plaid's public_token β access_token is the same pattern β same two-backend handoff as Google OAuth / "sign in with X."
postMessage has nothing to do with backends. Two communication types β do not conflate:
The messages are lifecycle events, not data β except SUCCESS carries the public_token into the customer's onSuccess, which is exactly why the channel must be locked down (pinned target origin on send, event.origin allowlist check on receive).
Two stacked sources of truth:
POST /link/session/:id/accounts. Final set = intersection of bank grant and user selection.Deselecting narrows the set server-side before the token is issued (re-submittable, overwrites). Widening beyond the grant requires going back through the bank's OAuth β the bank owns the grant, your widget cannot expand it alone.