Skip to main content
All posts
Engineering· by Nikhil Jathar

Cloudflare Workers + Shopify OAuth Pairing: How We Built a Pairing-Code Bridge

How we built a Cloudflare Worker bridge between Shopify's embedded App Bridge OAuth and a self-hosted CLI ERP. Token Exchange, HKDF-derived per-shop secrets, and the gotchas we hit.

A Shopify app has to live inside the Shopify admin. A self-hosted ERP lives on the customer’s own machine, behind their own firewall, with no public URL. Those two architectural requirements collide head-on, and how you resolve them is the difference between a five minute install and a multi-week support headache.

This is the engineering write-up of how we built the ERPClaw Shopify pairing bridge on Cloudflare Workers. Token Exchange, HKDF-derived per-shop secrets, the App Bridge gotchas, the things the Shopify docs do not tell you, and why we ended up here after rejecting three other approaches.

This post is for developers building Shopify apps that need to bridge to off-platform infrastructure. If you are also wrestling with “the embedded admin requires Token Exchange but I do not want to host a backend,” this is what we did.

The architectural problem in one paragraph

Shopify embedded apps run inside an iframe in the Shopify admin. They authenticate via Shopify App Bridge using session tokens, and they exchange those session tokens for offline access tokens via Token Exchange. The offline token is what you use to call the Admin API for that shop.

ERPClaw is a self-hosted ERP. It lives on the customer’s machine. There is no public URL we can point Shopify at. We cannot host the offline tokens because each customer’s data should stay on their machine. So how does the customer’s local ERPClaw process get the offline token without us, the app developer, hosting any per-customer infrastructure?

The answer is a stateless Cloudflare Worker that handles the OAuth dance, derives per-shop encryption keys deterministically, and hands the token to the customer’s CLI via a one-time pairing code. We never store the token. We never see the customer’s data. The Worker is a pure broker.

Why we rejected the obvious approaches

Before settling on Workers, we ran through three other approaches and rejected each. Useful for context.

Option 1: host a multi-tenant SaaS backend. Stand up a normal app server, store offline tokens per shop, serve as the Shopify app’s backend. Standard pattern. The reason we rejected it: it makes ERPClaw not really self-hosted anymore. The whole product positioning is “your data on your machine, not our cloud.” Hosting offline tokens centrally turns us into a SaaS company in everything but name. Also costs money to run. Also makes us responsible for breach risk on every customer’s Shopify data.

Option 2: have customers run a publicly accessible web server. Tell customers to expose their ERPClaw install on a public URL with TLS, and have Shopify call that directly. The reason we rejected it: small business customers do not run public web servers. They run a Mac on their desk. Asking them to set up port forwarding, a domain, a TLS certificate, and a static IP is a non-starter. Even with ngrok or Cloudflare Tunnel, the operational burden is too high for the install-in-five-minutes promise.

Option 3: use Shopify’s offline token in the embedded app and never sync to the local ERP. Just build the entire Shopify app as a web app and skip the local sync. The reason we rejected it: the whole point of the integration is that your accounting data lives in your local ERPClaw install, not in Shopify. The Shopify side is a thin source of truth for orders and customers; the local ERPClaw is where the GL, the chart of accounts, the AR aging, and the financial reports live.

So we needed a fourth option. A stateless broker that handles the OAuth dance, hands the token to the local CLI, and never holds long-lived state. That is the Cloudflare Worker.

The architecture

Three components:

  1. Cloudflare Worker (erpclaw-shopify-bridge) at a fixed URL. Stateless. Handles the OAuth dance with Shopify, derives per-shop encryption keys via HKDF, and brokers the pairing handoff.
  2. Shopify embedded app (Cloudflare Pages) that loads inside the admin iframe. Calls App Bridge, runs Token Exchange, displays the pairing code to the merchant.
  3. Local ERPClaw CLI on the customer’s machine. Calls a pair-shopify-shop action with the pairing code. The CLI talks to the Worker, gets the offline token, stores it locally encrypted, and never talks to the Worker again for that shop.

The flow:

Merchant clicks "Install" in Shopify App Store

Shopify redirects to embedded app URL

Embedded app loads in iframe

App Bridge produces session token

Embedded app calls Worker /pair endpoint with session token

Worker calls Shopify Token Exchange API with session token

Worker gets offline access token from Shopify

Worker derives a per-shop encryption key via HKDF(master_secret, shop_domain)

Worker generates a 6-character pairing code, encrypts the offline token with the per-shop key

Worker stores the encrypted token in KV with the pairing code as the key, TTL 10 minutes

Worker returns the pairing code to the embedded app, which shows it to the merchant

Merchant runs `erpclaw shopify pair --code XYZ123` on their local machine

CLI calls Worker /redeem endpoint with the pairing code

Worker derives the per-shop key (same input, same output, no storage needed)

Worker decrypts the offline token, returns it, deletes the KV entry

CLI stores the offline token locally encrypted by the merchant's machine identity

CLI confirms pairing complete to Worker, syncs first batch of orders

After this flow, the Worker never sees this merchant’s data again. The offline token lives only on the customer’s machine. The KV entry is gone. The pairing code is single-use.

Why HKDF for per-shop keys

If we encrypted every offline token with the same global key, a breach of that key would compromise every shop’s data. If we generated a random per-shop key, we would have to store it somewhere, which puts us back to “we hold per-customer state.”

The compromise is HKDF. We hold one master secret. The per-shop encryption key is HKDF(master_secret, shop_domain). The same shop always derives the same key. We never store the per-shop key; we re-derive it on demand. A breach of the master secret compromises everything (which is true of any encryption scheme), but the keys themselves are derived deterministically without storage.

In Worker code:

async function deriveShopKey(masterSecret: ArrayBuffer, shopDomain: string): Promise<CryptoKey> {
  const salt = new TextEncoder().encode("erpclaw-shopify-v1");
  const info = new TextEncoder().encode(shopDomain);
  
  const baseKey = await crypto.subtle.importKey(
    "raw", masterSecret, "HKDF", false, ["deriveKey"]
  );
  
  return crypto.subtle.deriveKey(
    { name: "HKDF", salt, info, hash: "SHA-256" },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

The salt is constant across shops; the info parameter is the shop domain, which is what makes the derived key shop-specific. This is the standard HKDF pattern from RFC 5869.

The master secret lives in Cloudflare Workers Secrets, encrypted at rest, never exposed in logs or environment dumps. Rotation is possible via a versioned salt (erpclaw-shopify-v2) which would invalidate all existing pairings and force re-pair, an acceptable operational tradeoff for an emergency rotation.

Token Exchange, the actual call

Token Exchange is Shopify’s OAuth 2.0 token exchange flow per RFC 8693. The embedded app gets a session token from App Bridge (a JWT signed by Shopify), and the Worker exchanges it for an offline access token by POSTing to the shop’s token endpoint.

async function exchangeForOfflineToken(
  shopDomain: string,
  sessionToken: string,
  apiKey: string,
  apiSecret: string
): Promise<{ access_token: string; scope: string }> {
  const response = await fetch(
    `https://${shopDomain}/admin/oauth/access_token`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: apiKey,
        client_secret: apiSecret,
        grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
        subject_token: sessionToken,
        subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
        requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
      }),
    }
  );
  
  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }
  
  return response.json();
}

The catch is that the session token must be validated before calling Token Exchange. If a malicious merchant page submits a fake session token, the Worker should reject it before contacting Shopify. Validation means verifying the JWT signature against the Shopify-provided JWK set, checking the aud claim equals our app’s API key, checking the iss claim matches the shop domain, and checking the exp claim is in the future.

async function validateSessionToken(
  sessionToken: string,
  apiSecret: string,
  expectedShop: string
): Promise<JWTPayload> {
  const payload = await jwtVerify(sessionToken, apiSecret, {
    audience: API_KEY,
    issuer: `https://${expectedShop}/admin`,
  });
  
  if (payload.exp * 1000 < Date.now()) {
    throw new Error("Session token expired");
  }
  
  return payload;
}

We learned this the hard way during the 2026-04-25 debug session. Without strict validation, the Worker happily forwards any session token to Shopify and Shopify happily issues an offline token if the JWT decodes. That is a bad day waiting to happen.

The cached-token gotcha

The single hardest bug in the pairing flow was a cached token issue that took most of a day to track down. Symptom: the merchant clicks Install, the embedded app loads, the Worker returns a pairing code. The merchant runs the CLI command. Nothing happens. Re-paired three times. Same result.

Root cause: App Bridge was returning a cached session token from the previous install attempt. The Worker validated the cached token (still valid, not expired), called Token Exchange, got a stale offline token from Shopify (which Shopify legitimately invalidates after Install Re-runs), and stored that in KV. The CLI redeemed it, got a 401 from Shopify on the first API call, and retried, and retried, and retried until it gave up.

The fix was twofold:

  1. Force fresh session token on every embed load. App Bridge has a forceRefresh: true option on getSessionToken() that bypasses the cache. We always pass it on the pairing flow.
  2. Reject session tokens older than 30 seconds at the Worker. Even if App Bridge returns a “fresh” token that is actually 10 minutes old, the Worker checks the iat claim and refuses anything older than 30 seconds. Belt and suspenders.

This is the kind of thing the Shopify docs do not call out. The session token is a JWT and JWTs have an expiration claim, but App Bridge silently caches them and there is no mention of cache invalidation in the embedded app docs. We learned by reading the App Bridge source on GitHub.

Cloudflare Pages for the embedded app

The embedded app itself (the part that loads in the iframe) is a static site on Cloudflare Pages. Astro build, no server-side rendering, just HTML and JS. App Bridge, Token Exchange call to the Worker, display pairing code, done.

Static site means we have no per-shop server-side state in the embedded app either. The whole architecture is stateless except for the 10-minute KV entry holding the encrypted pairing code.

Things we hit on Pages:

  • Custom domain on Pages requires the Cloudflare Pages app to own the zone. If the zone is on a different account, the routing breaks silently. We unified the zone and the Pages project under one account.
  • App Bridge requires HTTPS and a fixed redirect URL. Pages provides both for free.
  • The Shopify app config in Partners dashboard must list the exact embedded app URL. Mismatches produce a generic “App URL invalid” error with no detail. Triple check the URL.

Why we put this on Cloudflare instead of AWS Lambda

Three reasons.

Latency. Cloudflare Workers run at the edge globally. Shopify shops are distributed worldwide. A merchant in Australia hitting an AWS us-west-2 Lambda gets 200ms+ added to every request. A Cloudflare Worker is ~30ms from anywhere.

Cost. Workers have a generous free tier (100k requests/day) and the pairing flow is a few requests per merchant per install. We are paying $0 for the bridge today and will be paying $0 at 10,000 merchants.

KV TTL. Cloudflare KV has TTL built in. The 10-minute pairing code expiry is expirationTtl: 600 on the put. No cron job needed to clean up expired codes. AWS DynamoDB has a TTL feature too but it is approximate (up to 48 hours late) and not appropriate for “the pairing code MUST be unusable after 10 minutes.”

The downside of Workers is the runtime constraints. No Node-style filesystem, limited libraries, ESM only, 50ms CPU time limit per request. For a pairing broker this is fine. For the actual ERPClaw app, this would not work, which is why the ERP itself runs locally and not on Workers.

What this saves the customer

From the merchant’s perspective, the install flow is:

  1. Click “Install” in Shopify App Store. (Shopify side, ~15 seconds.)
  2. App loads in admin, shows a 6 character pairing code. (~3 seconds.)
  3. Run clawhub install erpclaw on local machine if not already installed. (~30 seconds.)
  4. Run erpclaw shopify pair --pairing-code XYZ123. (~5 seconds.)
  5. Done. ERPClaw is now syncing orders.

Total install time: under two minutes. No webhook configuration. No API key handling. No public URL setup. The merchant never touches a credential.

The Worker did the work the merchant did not have to do.

Open source the bridge?

The Worker code is in a private repo today. We may open source it once the Shopify app is publicly listed. The cryptographic primitives (HKDF, AES-GCM via WebCrypto, Token Exchange per RFC 8693) are all standard. The only secret is the master secret in Workers Secrets, which we rotate independently.

If you are building a similar bridge for a different platform (Stripe Apps, Square, Xero), the pattern is reusable. Stateless Worker, HKDF for per-tenant keys, short-lived KV for pairing codes, push the long-lived state to the customer’s machine.

Try the integration

The Shopify integration is currently in private beta. The Stripe integration is live on the Stripe Marketplace. Both use the same architectural pattern (broker for the OAuth dance, customer holds the data) for the same reason.

ERPClaw is open source, runs on SQLite or PostgreSQL via PyPika, and the Stripe + Shopify integrations are deep, audit-grade, and free.

FAQ

Can I reuse this Worker bridge pattern for my own Shopify app?

Yes, the pattern is reusable. The components are: a Cloudflare Worker (or any edge function) that handles Token Exchange, an HKDF derivation for per-shop keys, a short-lived KV/Redis entry for the pairing code, and a CLI on the customer’s machine that redeems the code. None of the pieces are ERPClaw-specific.

What happens if the merchant loses the pairing code?

The code expires in 10 minutes regardless. The merchant goes back to the Shopify admin, reloads the app, and gets a new pairing code. Token Exchange re-runs and a fresh offline token is issued by Shopify. No state to clean up.

How do you handle Shopify webhook delivery if the customer’s machine is behind a firewall?

Webhooks are delivered to the Worker, not directly to the customer’s machine. The Worker writes the webhook to a queue (Cloudflare Queues or KV with a known prefix) and the customer’s local CLI polls the Worker on a schedule (every 30 seconds by default) to pull pending webhooks. This is the inverse of the typical webhook flow but works without exposing the customer’s machine.

Is the offline token at risk if the Worker is breached?

The offline token is encrypted in KV with the per-shop derived key. The master secret is in Cloudflare Workers Secrets, separate from the Worker code. A breach of the Worker code alone does not expose tokens; an attacker needs both the code and the master secret. We treat the master secret as the only crown-jewel credential and rotate via versioned salt if needed.

Why not use Shopify’s official Node template?

The official template assumes you are hosting a multi-tenant backend that holds offline tokens centrally. We are not. The architecture is fundamentally different because ERPClaw is self-hosted, not SaaS.

How does ERPClaw handle Shopify rate limits?

The Shopify API has a rate limit of 2 requests per second per shop (40 in burst). The local ERPClaw CLI implements exponential backoff plus a token bucket per shop. The Worker does not call Shopify on the merchant’s behalf except during pairing, so it does not hit shop-specific rate limits.

Is the bridge open source?

Not yet. The cryptographic logic is standard (HKDF, AES-GCM, Token Exchange per RFC 8693) and the architecture is described above. We may open source the Worker code once the Shopify app exits private beta.

Install ERPClaw and try the Stripe integration today; the Shopify integration is rolling out to private beta.

Tagscloudflareworkersshopifyoauthengineeringai-native