OAuth

Declare connector OAuth, use host-managed tokens at runtime, and test OAuth routes locally.

Use connection.oauth when the provider expects an authorization-code flow. Backfill owns state, callback handling, token exchange, encrypted token storage, refresh, disconnect, and reauth status. Connector code declares what it needs and receives a runtime token API.

API-key connectors should keep using connection.settingsSchema secrets and Secrets.get(...). Do not build a custom OAuth redirect route inside a connector.

Manifest

// backfill.config.ts
import { defineConnector } from "@backfill-io/sdk";

export default defineConnector({
  key: "shopify",
  name: "Shopify",
  provider: "shopify",
  kind: "source",
  permissions: {
    http: ["https://*.myshopify.com/*"],
    entities: {
      Customer: ["write"],
      Item: ["write"],
      SalesReceipt: ["write"],
      RefundReceipt: ["write"],
    },
    secrets: {
      webhook_secret: ["read"],
    },
  },
  connection: {
    testConnection: { route: "/setup/test_connection", method: "POST" },
    oauth: {
      type: "authorization_code",
      authorizationUrlTemplate:
        "https://{settings.shop_domain}/admin/oauth/authorize",
      tokenUrlTemplate:
        "https://{settings.shop_domain}/admin/oauth/access_token",
      scopes: [
        "read_customers",
        "read_products",
        "read_orders",
        "read_refunds",
      ],
      accessType: "offline",
      pkce: false,
      requiredSettings: ["shop_domain"],
      client: {
        credentialKey: "shopify_public_app",
      },
      token: {
        accessTokenPath: "access_token",
        scopePath: "scope",
      },
      refresh: { enabled: false },
      postConnect: {
        route: "/setup/test_connection",
        method: "POST",
      },
      hostValidation: {
        shop_domain: {
          suffix: ".myshopify.com",
          normalize: "shopifyShopDomain",
        },
      },
    },
    setup: {
      instructions:
        "Connect Shopify with OAuth, then configure the generated webhook URL in Shopify.",
    },
    settingsSchema: {
      type: "object",
      required: ["shop_domain"],
      properties: {
        shop_domain: {
          type: "string",
          title: "Shop domain",
          "x-placeholder": "acme.myshopify.com",
        },
        webhook_secret: {
          type: "string",
          title: "Webhook signing secret",
          "x-secret": true,
        },
      },
    },
  },
});

Manifest fields

FieldUse
typeCurrently authorization_code.
authorizationUrlTemplateProvider authorization URL. Supports {settings.<name>} placeholders.
tokenUrlTemplateProvider token exchange and refresh URL. Supports {settings.<name>} placeholders.
scopesRequired provider scopes. The host blocks OAuth starts if the selected client credential does not allow every requested scope.
scopeSeparatorOptional scope joiner. Defaults to provider/runtime behavior. Use " " or "," when the provider requires it.
accessTypeOptional provider hint such as "offline" or "online".
pkceSet true for providers that require PKCE.
requiredSettingsSettings that must exist before the Connect button can start OAuth.
additionalAuthorizeParamsStatic provider parameters added to the authorization request.
additionalTokenParamsStatic provider parameters added to token exchange and refresh requests.
clientHost-owned OAuth app credential handle.
tokenPaths for reading token response fields.
refreshWhether the host should refresh tokens.
postConnectConnector route called after token storage to validate the connection and capture provider metadata.
hostValidationRules that validate dynamic OAuth hosts before a redirect or token request is made.

OAuth app credentials

connection.oauth.client tells the host which company-managed OAuth app credentials to use. Connector runtime code never sees the client secret.

client: {
  credentialKey: "shopify_public_app",
}

Backfill currently documents only company-managed OAuth clients: a company admin creates or brings an OAuth app in the provider, enters the app’s client id and client secret in Backfill, and then each connector connection completes its own provider authorization. Publisher-managed OAuth apps need a separate publishing model and are intentionally not part of this public contract yet.

Client secrets are write-only. Admin forms accept a secret during create/rotate, but read APIs and connector runtime APIs only expose non-secret metadata such as client_id, credentialKey, status, scopes, and rotation timestamps.

Company credential registration

The connector declares the credential handle:

client: {
  credentialKey: "private_app",
}

Backfill must have a company OAuth app credential registered for that handle before OAuth start can succeed. The self-serve admin form for managing these credentials is still pending; until it ships, credentials need to be provisioned by a Backfill operator or internal setup path.

The admin-only credential form should collect:

  • client id
  • client secret
  • allowed scopes
  • redirect URI, when the provider requires a pre-registered callback
  • label or metadata that helps the admin recognize the app

The client secret field is write-only. After save, the UI should show only non-secret metadata, credential status, allowed scopes, last rotation time, and revoke/rotate actions. Starting OAuth fails if the credential is missing, revoked, provider-mismatched, connector-mismatched, or if the connector requests scopes outside allowed_scopes.

Company OAuth app credentials are not reusable across companies. Each connector connection still completes a separate provider authorization and stores separate access/refresh tokens.

Dynamic OAuth URLs

Use dynamic URLs only when the provider requires tenant-specific hosts. Shopify is the common case because every shop has its own *.myshopify.com host.

authorizationUrlTemplate:
  "https://{settings.shop_domain}/admin/oauth/authorize",
tokenUrlTemplate:
  "https://{settings.shop_domain}/admin/oauth/access_token",
hostValidation: {
  shop_domain: {
    suffix: ".myshopify.com",
    normalize: "shopifyShopDomain",
  },
},

hostValidation is required for any setting that appears in the URL host. A rule must declare suffix or exact. The optional normalize value can canonicalize user input before validation.

normalize: "shopifyShopDomain":

  • trims whitespace
  • lowercases the value
  • removes http:// or https://
  • drops any path
  • appends .myshopify.com when the user entered only the shop slug

For fixed-host providers, do not use dynamic host placeholders:

oauth: {
  type: "authorization_code",
  authorizationUrlTemplate: "https://connect.squareup.com/oauth2/authorize",
  tokenUrlTemplate: "https://connect.squareup.com/oauth2/token",
  scopes: ["PAYMENTS_READ", "ORDERS_READ", "CUSTOMERS_READ"],
  pkce: false,
  client: {
    credentialKey: "square_public_app",
  },
  refresh: { enabled: true },
  token: {
    accessTokenPath: "access_token",
    refreshTokenPath: "refresh_token",
    expiresInPath: "expires_in",
    scopePath: "scope",
  },
}

Runtime API

Use OAuth.accessToken() inside setup, sync, and webhook routes that call the provider.

import { OAuth, api } from "@backfill-io/sdk";

export const POST = api(async () => {
  try {
    const accessToken = OAuth.accessToken({ minTtlSeconds: 600 });
    return api.json({ ok: true, hasToken: Boolean(accessToken) });
  } catch (error) {
    if (
      error &&
      typeof error === "object" &&
      (error as { code?: string }).code === "reauth_required"
    ) {
      return api.badRequest("Reconnect this connector");
    }

    throw error;
  }
});

OAuth.connected() returns whether the current route has a usable token and all required scopes.

OAuth.tokenInfo() returns non-secret metadata:

const info = OAuth.tokenInfo();
// {
//   provider: "shopify",
//   connected: true,
//   scopes: ["read_orders"],
//   expiresAt: null,
//   providerAccountId: "gid://shopify/Shop/1",
//   providerAccountName: "Demo Shop",
// }

If the provider accepts bearer tokens, use the HTTP convenience auth helper:

const response = Http.get("https://api.example.com/v1/orders", {
  auth: { oauth: { minTtlSeconds: 300 } },
});

For providers like Shopify that require a provider-specific header, read the token and set that header yourself:

const response = Http.post(
  `https://${shopDomain}/admin/api/2026-04/graphql.json`,
  { query },
  {
    headers: {
      "X-Shopify-Access-Token": OAuth.accessToken({ minTtlSeconds: 600 }),
    },
  },
);

Do not log access tokens or refresh tokens. The host redacts known token values from runtime logs and route errors, but connector code should still treat token material as sensitive.

Testing

@backfill-io/sdk/testing can seed OAuth state without a live provider:

import { connectorTestRuntime } from "@backfill-io/sdk/testing";

const runtime = connectorTestRuntime({
  settings: { shop_domain: "demo.myshopify.com" },
  connector: { key: "shopify", provider: "shopify" },
  oauth: {
    accessToken: "test_access_token",
    scopes: ["read_orders", "read_customers"],
    requiredScopes: ["read_orders"],
    provider: "shopify",
    providerAccountId: "gid://shopify/Shop/1",
    providerAccountName: "Demo Shop",
  },
});

Useful OAuth fixture cases:

  • connected token with required scopes
  • missing token: oauth: { accessToken: null }
  • scope mismatch: set requiredScopes to include a scope not present in scopes
  • expired refreshable token: set expiresAt in the past and provide refresh
  • permanent refresh failure: set refreshError
  • bearer helper: call Http.get(..., { auth: { oauth: true } }) and assert the request shape