Plaid Link β€” Frontend Systems Design

Build an embeddable account-linking / authorization widget Β· RADIO framework Β· Deep dives: security, OAuth, idempotency

⏱️ 60-Minute Interview Guide (Cheatsheet)

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.

Do not miss

Where to spend "O" time (in priority order)

  1. OAuth redirect & SPA rehydration (the core problem β€” the SPA has amnesia after redirect)
  2. Idempotency & anti-optimistic-advance (state machine, single-use nonce, Idempotency-Key)
  3. Security surfaces: iframe isolation, postMessage origin checks, CSRF state β€” name them as three separate boundaries
  4. Edge cases & resilience, then abuse prevention if time remains
"The server owns the flow state, the client only renders it."

Contents

Problem Framing

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.

Frame it as: "an SPA that must rehydrate cleanly after a redirect," not CSR vs MPA.

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.

RRequirements

Functional clarifying questions

Non-functional clarifying questions

The questions above resolve to the committed scope below. State this out loud β€” "so I'm building this, not that" β€” before moving to architecture.

Functional requirements (in scope)

Non-functional requirements (in scope)

AArchitecture

Trust boundary to draw explicitly: credentials live only between the user's browser and the bank. That single line answers half the security questions.

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;

DData Model / Core Entities

πŸ—„οΈ Plaid Server β€” persisted database internals

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 fieldWhat it isExposed to
session_idInternal 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_tokenClient-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_stepAuthoritative FlowState. The server is the source of truth for the current step. Client renders it, never asserts it.Reported to client via GET
institution_idThe bank chosen in SELECT_INSTITUTION.Client selects, server persists
oauth_state_idServer-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_accountsAccount 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_atSession lifetime bounds (~4 hours).Server-internal

FlowState (enum) β€” the anti-optimistic-advance mechanism

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

Other entities

The token gradient (one idea repeated)

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.

TokenRoleLifetimeWhere it lives
link_tokenEntry ticket β€” authorizes opening one Link session with a given configMinutes–hoursBrowser (safe: scoped, expiring)
public_tokenThrowaway receipt / claim check β€” can only be exchanged, grants no data access~30 minBrowser β†’ customer backend
access_tokenThe crown jewel β€” actually pulls balances and transactionsLong-livedServer only. Never the browser.

IInterfaces

SDK surface for the customer

iframe ↔ host over postMessage

Every message validated against an origin allowlist, both directions. postMessage is browser-to-browser (two frames in one tab), not browser-to-backend.

DirectionMessages
widget β†’ hostREADY, SUCCESS (carries public_token + metadata), EXIT, EVENT
host β†’ widgetOPEN, CLOSE

Backend REST (idempotent by design)

EndpointPurposeRead/Write
POST /link/sessionCreates the session from the link_tokenWrite (server assigns id β†’ POST, not PUT)
GET /link/session/:idLets 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/institutionSelects an institutionWrite
POST /link/oauth/callbackFinalizes 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/accountsPersists the user's account selection (re-submittable β€” overwrites the set)Write
POST /public-token/exchangeCustomer 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"

OOptimizations / Deep Dives

1. OAuth redirect and rehydration CORE PROBLEM

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:

  1. sessionStorage breadcrumb. Before redirecting, persist the session handle (link_token). Works when the redirect returns in the same tab.
  2. The oauth_state_id in the callback URL. The bank echoes it back; the server can look up which session issued it. Works even in a new tab with empty sessionStorage.

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."

The return leg: one guarded write, one safe read

  1. SPA reloads with amnesia. Read oauth_state_id from the URL, session handle from sessionStorage.
  2. 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.
  3. 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

2. Not optimistically advancing

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.

3. Idempotency

4. Security THREE SEPARATE BOUNDARIES

Same-origin policy isolates the iframe by default, postMessage with pinned origins is the one controlled channel through it, and CSRF state protects the redirect that leaves the browser entirely. Three boundaries, three mechanisms.

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 trust rule, stated precisely

The client only ever holds values that are safe to leak. Anything whose exposure would break security stays server-side.

5. Edge cases & resilience

CaseHandling
LoadingSkeleton states in-widget; host shows nothing until the iframe posts READY. Institution list paginated/searched server-side.
ErrorsEvery 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.
RetryOAuth 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.
CancelUser closes modal or ESC β†’ widget posts EXIT (with step metadata), server marks session abandoned. Reopening requires a fresh or still-valid link_token.
Timeoutlink_token and LinkSession carry expires_at (~4h). An expired session rejects all transitions; widget shows "session expired, restart." The nonce expires in minutes, independently.
ResumeThe 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 buttonBack 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 redirectFirst 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).

6. Abuse prevention

Further Notes β€” Confusions Resolved

POST vs PUT

  1. Creation where the server assigns the id is POST. 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.
  2. The action endpoints (select-institution, oauth/callback) are state transitions with side effects, not resource replacements. 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).

sessionStorage vs localStorage, and why still GET

link_token vs session_id β€” two different things

What oauth_state_id is, exactly

It is the OAuth state parameter, specialized. One value, three guarantees:

  1. Correlation. The SPA is wiped on redirect; the bank echoes the state back on the return URL so the returning page knows which session it is resuming.
  2. CSRF protection. On return, the server checks the value matches one it actually issued for a live in-flight session. Forged callbacks won't match.
  3. Replay / idempotency. Single-use β€” once a callback consumes it, it is burned, so a duplicated redirect cannot finalize twice.

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.

Why both session_id AND oauth_state_id

The server-to-server exchange ("client" is overloaded)

In the exchange step, "client" means the customer's backend server (API client), not the browser. Two totally separate HTTP requests:

  1. Browser β†’ customer backend: widget fires onSuccess(public_token); the frontend sends the public_token up to its own backend.
  2. Customer backend β†’ Plaid: a brand-new server-to-server HTTPS call to /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 is browser-to-browser

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).

ACCOUNT_SELECT β€” who confirms what

Two stacked sources of truth:

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.

Final rehearsal lines: "The server owns the flow state, the client only renders it." Β· "The client only holds values that are safe to leak." Β· "GET is the safe read that survives refresh; POST is the guarded write that fires once."