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
| Field | Use |
|---|---|
type | Currently authorization_code. |
authorizationUrlTemplate | Provider authorization URL. Supports {settings.<name>} placeholders. |
tokenUrlTemplate | Provider token exchange and refresh URL. Supports {settings.<name>} placeholders. |
scopes | Required provider scopes. The host blocks OAuth starts if the selected client credential does not allow every requested scope. |
scopeSeparator | Optional scope joiner. Defaults to provider/runtime behavior. Use " " or "," when the provider requires it. |
accessType | Optional provider hint such as "offline" or "online". |
pkce | Set true for providers that require PKCE. |
requiredSettings | Settings that must exist before the Connect button can start OAuth. |
additionalAuthorizeParams | Static provider parameters added to the authorization request. |
additionalTokenParams | Static provider parameters added to token exchange and refresh requests. |
client | Host-owned OAuth app credential handle. |
token | Paths for reading token response fields. |
refresh | Whether the host should refresh tokens. |
postConnect | Connector route called after token storage to validate the connection and capture provider metadata. |
hostValidation | Rules 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://orhttps:// - drops any path
- appends
.myshopify.comwhen 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
requiredScopesto include a scope not present inscopes - expired refreshable token: set
expiresAtin the past and providerefresh - permanent refresh failure: set
refreshError - bearer helper: call
Http.get(..., { auth: { oauth: true } })and assert the request shape