Module: Core-02

Authorization

This guide covers two SaveLayer channels only—Headless and Customer account extensions. Online Store themes use the Shopify app proxy instead (signed URLs, no JWT exchange here). For Headless and customer-account, clients exchange proof for a short-lived SaveLayer JWT, then call operation routes with Authorization Bearer. The two direct API namespaces stay separate; minting, verification, and business logic are shared.

01 / CHANNELS

Which channels use this page

Online Store — Shopify app proxy and theme extension (/proxy/api/* on the app, window.SaveLayer in the storefront). Not covered on this page — no SaveLayer JWT exchange; requests use Shopify-signed proxy URLs and logged-in customer context. See API reference.

Headless — direct API /api/headless/*. Covered below — backend exchange plus SaveLayer JWT for operations.

Customer account — direct API /api/customer-account/*. Covered below — session-token exchange plus SaveLayer JWT for operations.

Plan gating still applies per channel (e.g. Headless on Scale, Customer account on Pro+); see 07 / PLANS below. Online Store proxy traffic follows Online Store plan limits separately.

02 / MODEL

Exchange, then SaveLayer bearer

Headless and Customer account follow the same pattern. First, call an exchange route. SaveLayer validates the shop install, checks plan channel entitlement (ShopGate), resolves the customer, and returns data.authorization as Bearer <jwt> plus metadata.

Second, call operation routes (save, list, remove, etc.) with Authorization: Bearer <SaveLayer JWT> only. Request body fields are not trusted for identity—the JWT carries shop, channel, and customer claims verified server-side.

Namespaces are fixed: headless uses /api/headless/*; customer-account uses /api/customer-account/*. A JWT minted for one channel includes a channel claim and must not be used against the other namespace (SaveLayer returns 403). Shared pieces include token format, @savelayer/contracts envelopes, and the same service dispatcher behind the routes.

For request and response shapes on each operation, see API reference.

03 / HEADLESS

Backend-only exchange

Your storefront server (never the browser) calls POST /api/headless/auth/exchange. The primary (recommended) body is JSON { "shop": "<myshopify-domain>", "customerAccountAccessToken": "<token>" } where the token is a valid Customer Account API access token from Shopify’s OAuth flow for that shop.

SaveLayer discovers the Customer Account GraphQL endpoint from https://{shop}/.well-known/customer-account-api, runs a customer { id } query with that token, then mints the SaveLayer JWT. There is no browser-direct headless exchange; keep secrets and token exchange on your backend.

Legacy Storefront token alternative: For older headless storefronts using legacy Storefront API customer tokens, the same endpoint accepts { "shop": "...", "storefrontCustomerAccessToken": "<legacy-token>", "storefrontAccessToken": "<shop-public-token>" }. SaveLayer calls the Storefront API customer { id } query to resolve the customer. The response shape and JWT are identical. Legacy tokens can be long-lived (up to 90 days); SaveLayer’s JWT remains 300 seconds regardless. The request must contain either the modern or legacy token fields—not both.

If discovery or customer resolution fails, the exchange returns 401 with code INVALID_HEADLESS_SIGNATURE (modern path) or INVALID_LEGACY_STOREFRONT_TOKEN (legacy path).

Typical headless calls are server-to-server; you do not rely on browser CORS to reach SaveLayer for exchange. Use your server to call SaveLayer and forward only the short-lived JWT to your frontend if needed, or keep all SaveLayer calls on the server.

04 / CUSTOMER ACCOUNT

Session token exchange and CORS

Customer account UI extensions call POST /api/customer-account/auth/exchange. SaveLayer uses Shopify’s authenticate.public.customerAccount: the session token supplies shop (dest) and customer (sub as customer GID)—not free-form body fields for identity.

Exchange success and error responses are wrapped with Shopify’s cors(...) helper so extension origins receive appropriate Access-Control-* headers on that route.

Other /api/customer-account/* routes handle OPTIONS through the same customer-account authenticate entrypoint (preflight), then serve standard SaveLayer JSON for the actual request once the SaveLayer JWT is present.

05 / TOKEN LIFETIME

TTL and refresh

Access tokens expire in 300 seconds (five minutes), defined as DIRECT_ACCESS_TOKEN_TTL_SECONDS in @savelayer/contracts. The exchange response includes expiresInSeconds aligned with that value.

When a JWT expires, operation routes reject it; re-run the exchange to obtain a new bearer token. Do not embed customer identity in payloads to bypass this—SaveLayer ignores unauthenticated identity fields for authorization.

06 / SECRET

DIRECT_API_JWT_SECRET

SaveLayer signs and verifies direct API JWTs with HS256 using the runtime binding DIRECT_API_JWT_SECRET. Use a long random string; treat it like any other signing key.

Local development: set DIRECT_API_JWT_SECRET in apps/pages/.dev.vars (copy from apps/pages/.dev.vars.example). Mirror the same value in the repo root .env so Vite’s Pages dev overlay can inject it into loader/action context.

The secret is not listed under [vars] in apps/pages/wrangler.toml—it is supplied via .dev.vars locally and via Cloudflare Pages secrets in deployed environments.

Deployed: add DIRECT_API_JWT_SECRET as a secret in both GitHub Environments used by deploys (Staging and Production). The deploy workflow runs wrangler pages secret put DIRECT_API_JWT_SECRET on the app Pages project only. The Worker does not receive this secret—minting and verification run on Pages.

07 / PLANS

Channel gating

Exchange and protected routes check that the shop’s plan allows the channel. Headless requires the Scale plan. Customer account requires Pro or above (Pro includes customer-account; Scale adds headless). Online Store ingress via app proxy is separate and follows plan limits for the Online Store channel.

08 / SECURITY

Boundaries and non-goals

SaveLayer does not trust client-supplied customer IDs for authentication. The exchange proves identity via Shopify (Customer Account token or customer-account session token); subsequent calls prove it via the signed JWT.

SaveLayer does not use a D1 or KV table for direct API sessions—the JWT is self-contained and short-lived. The headless exchange supports both modern Customer Account API tokens (preferred) and legacy Storefront customer access tokens as exchange proof.

The JWT channel claim enforces namespace separation. Do not reuse tokens across headless and customer-account routes.