Customer access and invitations
Require a signed-in shopper or a narrow, expiring invitation before a work runs.
Choose the proof your build needs
Use auth: "customer" for signed-in storefront customers, on a theme or a headless storefront such as Hydrogen, or auth: "token" for an invitation that permits a specific action. Public works accept anonymous requests. Merchant auth is for merchants and connected agents. All customer-facing modes use automatic traffic protection; logging in does not exempt a caller from limits. Customer and invitation responses are never cached at the edge; only public GET works can be.
A signed-in storefront customer
Declare an HTTP trigger with auth: "customer", GET or POST, and the exact storefront origin. The shared Shopify app proxy forwards /apps/littleworks/your.work (the real URL of a published work) and /apps/littleworks/preview/{token}/your.work (the preview URL, which tests the newest version) to Littleworks. One proxy serves every work. deploy_function and get_function return both paths. Shopify can customize the proxy path; verify the installed path before wiring the frontend. Littleworks requires write_app_proxy, separately from optional Shopify data permissions.
Littleworks verifies Shopify’s query signature, timestamp and store, then requires a nonempty customer ID. The work receives context.customer.id as a Shopify customer GID. At the direct /f/... URL, customer identity comes only from an assertion signed by a headless storefront’s server (below); invoke_function cannot supply it. GET input excludes Shopify’s proxy parameters. POST accepts JSON.
export default async ({ context, db }) => {
return db.collection("requests").list({
where: { customerId: context.customer.id },
});
};const response = await fetch("/apps/littleworks/customer.requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (response.status === 401) {
// Ask the shopper to sign in using this store's customer login flow.
}This mode supports storefront sessions where Shopify supplies logged_in_customer_id. Test the store’s actual customer-account setup; if Shopify supplies no customer ID, the request fails closed. Headless storefronts use the signed assertion below instead. Customer-account UI extensions need separate identity integration and cannot use this mode to impersonate a shopper. CORS and Origin are not proof of identity.
A signed-in customer on a headless storefront (Hydrogen)
Hydrogen and other headless storefronts have no app proxy, but their server already knows the signed-in customer from Shopify’s Customer Account API. That server signs a short assertion naming the customer and calls the work’s direct URL with it. The same auth: "customer" work serves both paths and receives the same context.customer.id, so one work can back a theme and a Hydrogen storefront. deploy_function, get_function and list_functions return a customer work’s direct URLs as headlessUrl (once published) and headlessPreviewUrl: https://littleworks.app/f/{store}/{name} and https://littleworks.app/f/{store}/preview/{token}/{name}.
- Create the key once, in two places. In the Littleworks app in Shopify, open Secrets, add
LITTLEWORKS_CUSTOMER_KEYand choose Generate value. Copy the value it shows once into the storefront server’s environment (for Oxygen, the storefront’s environment variables) under the same name. The key must be at least 32 bytes. - Sign on the server for each request. Read the customer ID from
context.customerAccount, sign a JWT with HS256, and send it asAuthorization: Bearer <assertion>to the work’sheadlessUrl(orheadlessPreviewUrlwhile testing). - Use `context.customer.id` in the work, exactly as for the app proxy.
| Part | Required value |
|---|---|
| Header | Exactly {"alg":"HS256","typ":"JWT"}. Other algorithms, none and extra header fields are rejected. |
| Key | The UTF-8 bytes of the LITTLEWORKS_CUSTOMER_KEY value, used as the HMAC-SHA256 key. |
sub | The customer GID from the Customer Account API, such as gid://shopify/Customer/123. |
aud | "{store}/{work}": the store ID and work name from the URL. An assertion for one work does not open another. |
iat, exp | Whole seconds. exp is at most 300 seconds after iat; use 60 or less. Littleworks allows 30 seconds of clock difference. |
buyerIp | Optional: the shopper’s IP address (on Oxygen, the oxygen-buyer-ip request header). Littleworks forwards it to the Storefront API as the buyer IP hint and never uses it for identity. |
| Size | At most 2 KiB. |
// app/lib/littleworks.server.ts: runs only on the storefront server.
const encoder = new TextEncoder();
const base64url = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const encode = (value: unknown) => base64url(encoder.encode(JSON.stringify(value)));
/** Calls a customer work as the signed-in customer. workUrl is its headlessUrl. */
export async function callAsCustomer(
context: { env: { LITTLEWORKS_CUSTOMER_KEY: string }; customerAccount: any },
request: Request,
workUrl: string,
input: unknown,
) {
// The customer comes from Shopify's Customer Account API session, never from input.
if (!(await context.customerAccount.isLoggedIn())) return null;
const { data } = await context.customerAccount.query(`#graphql
query LittleworksCustomer { customer { id } }`);
const customerId: string = data.customer.id; // gid://shopify/Customer/...
// aud is "{store}/{work}", taken from the URL: /f/{store}/{work} or /f/{store}/preview/{token}/{work}.
const path = new URL(workUrl).pathname.split("/");
const aud = `${decodeURIComponent(path[2])}/${decodeURIComponent(path[path.length - 1])}`;
const iat = Math.floor(Date.now() / 1000);
const buyerIp = request.headers.get("oxygen-buyer-ip");
const unsigned = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
sub: customerId, aud, iat, exp: iat + 60, ...(buyerIp ? { buyerIp } : {}),
})}`;
const key = await crypto.subtle.importKey(
"raw", encoder.encode(context.env.LITTLEWORKS_CUSTOMER_KEY),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(unsigned)));
return fetch(workUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${unsigned}.${base64url(signature)}` },
body: JSON.stringify(input),
});
}// app/routes/api.points.tsx: the storefront calls its own route; the route calls Littleworks.
import { callAsCustomer } from "~/lib/littleworks.server";
export async function action({ request, context }) {
const response = await callAsCustomer(context, request, context.env.LITTLEWORKS_POINTS_URL, await request.json());
if (!response) return Response.json({ error: "Sign in to see your points." }, { status: 401 });
return new Response(response.body, { status: response.status, headers: { "Content-Type": "application/json" } });
}Call from the storefront server, server to server. It needs no allowed origins and keeps the assertion away from the browser. If the browser must call Littleworks directly, have a loader hand it a fresh assertion that expires within a minute and list the storefront’s exact origin in the work’s origins: until it expires, that assertion acts as the customer for that work.
To rotate the key, add the new value as LITTLEWORKS_CUSTOMER_KEY_NEXT; Littleworks accepts assertions signed with either. Switch the storefront to the new value and deploy it, then replace LITTLEWORKS_CUSTOMER_KEY with the new value and delete LITTLEWORKS_CUSTOMER_KEY_NEXT. If the key leaks, generate a new one at once and update the storefront.
| Response | Meaning |
|---|---|
401 CUSTOMER_AUTH_REQUIRED | No assertion (and no app proxy signature). Sign the customer in, or check that the server sends the Authorization header. |
401 CUSTOMER_ASSERTION_NOT_CONFIGURED | The store has no LITTLEWORKS_CUSTOMER_KEY of at least 32 bytes. |
401 INVALID_CUSTOMER_ASSERTION | The signature, header, sub, aud, times, buyerIp or size is wrong. Check that both places hold the same key and that aud matches the URL. |
401 CUSTOMER_ASSERTION_EXPIRED | exp has passed. Sign a new assertion for each request, and check the server clock. |
Public, token and merchant works ignore customer assertions: a token work still needs its invitation token in the Authorization header, and a public work never receives context.customer. Customer works keep the same traffic protection; Littleworks counts a signed-in customer as one visitor wherever their requests come from.
An invitation for a guest
First deploy the receiving HTTP work with auth: "token". Then use a private callable or merchant HTTP work to issue an invitation after checking eligibility. A public, customer or token work cannot mint invitations, even when invoked through an agent. The issuer and the target belong to the same store. A real run issues invitations for the target’s current version, so the target must be published; a test run issues them for the target’s newest version, which its preview URL runs.
const invitation = await auth.issueToken({
work: "reviews.submit",
claims: { orderId: verifiedOrderId, productId: verifiedProductId },
expiresInSeconds: 86400,
singleUse: true,
});
// Deliver invitation.token only to its intended recipient through an
// authorized integration. Never log it or store it in a public record.The result contains token and expiresAt. Tokens are opaque bearer credentials: anyone possessing one can perform its permitted action. Only a token hash and encrypted claims are stored by Littleworks. Defaults are one hour and single use; expiry may be 60 seconds to seven days. Claims are limited to 4 KiB and stores may have 1,000 unexpired tokens, including consumed tokens until expiry. Keep claims minimal, using IDs rather than personal details.
export default async ({ input, context, db }) => {
const { orderId, productId } = context.action.claims;
return db.collection("reviews").create({
orderId,
productId,
rating: input.rating,
status: "pending",
});
};Send the token in Authorization: Bearer <token> to the target’s URL: the real URL for an invitation from a real run, or the preview URL for one from a test run. It is never accepted from an input field or query parameter. For an email invitation, link to the build’s frontend with the token in the URL fragment; the frontend should read it, remove it from the address bar and send it in the Authorization header. Littleworks does not host that frontend. Fragments still need care: avoid exposing them to analytics or other scripts.
Each token binds to the exact store, work, method and version, and is accepted only while that version is the one that runs. Publishing another version invalidates invitations issued for the previous current version, and deploying a new version invalidates test invitations for the previous newest version. Publishing an earlier version again can make its unexpired, unused invitations valid again. Pause the work to stop all new executions. Choose a reusable token only for a build that needs repeated access, with a suitably short expiry.
Input and admission failures do not consume a single-use token. Admission and consumption are atomic, so concurrent requests cannot use it twice. Once execution starts, it stays consumed even if the work fails or returns an error, because earlier effects may have completed. Inspect the run before issuing another invitation. Tokens and customer login do not make external effects transactional.
Automatic protection
Littleworks limits bursts, simultaneous runs, per-work and store execution, stored data, and external requests. See Limits and security. Rejected admissions create no run and spend no run allowance. Littleworks HTTP 429 for a time-based allowance includes Retry-After and a retry time; storage needs space freed instead. Edge flood blocks may return a non-JSON response and appear only in Cloudflare security events. Back off, and do not automatically replay a mutation. Shopify shows traffic notices on affected works and explanations on failed runs.