Shopify Customer Account API: Log Customers In

Shopify Customer Account API authentication is an OAuth 2.0 authorisation code flow. You discover the shop’s endpoints from a well-known URL, redirect the customer to the authorization endpoint with the scope openid email customer-account-api:full, then exchange the returned code at the token endpoint for an access token. Public clients add PKCE on top.

The flow it replaces is still live in a lot of storefronts, and Shopify has now put an end date on it.

Why the old Storefront API login flow is finished

The Storefront API’s customerAccessTokenCreate still exists in version 2026-07, but its reference page now carries a single line of scope: “For legacy customer accounts only.” Legacy customer accounts were deprecated on 26 February 2026. They are no longer available to new stores, or to existing stores that were not already using them, and Shopify says a final sunset date will be announced later in 2026.

The bridge mutation went first. storefrontCustomerAccessTokenCreate, which existed to swap a Customer Account API token for a Storefront API one, was deprecated on 1 January 2025 on the grounds that it is no longer necessary. The Storefront API now takes a Customer Account API token directly through BuyerInput.customerAccessToken.

Which leaves the Customer Account API as the login flow for any headless storefront that is not already tied to legacy accounts.

How does Shopify Customer Account API authentication work?

Four steps: discover, authorize, exchange, query.

Discovery is the step most tutorials skip. The 2026-07 reference tells you to read the endpoints from the shop’s storefront domain rather than construct them. GET /.well-known/openid-configuration returns authorization_endpoint, token_endpoint, end_session_endpoint and jwks_uri. A second document, GET /.well-known/customer-account-api, returns graphql_api and mcp_api, and the GraphQL URL already has the current version in it. Do this once at the start of the flow and reuse the result.

const config = await fetch(
  `https://${shopDomain}/.well-known/openid-configuration`,
).then((r) => r.json());

const authUrl = new URL(config.authorization_endpoint);
authUrl.searchParams.append('scope', 'openid email customer-account-api:full');
authUrl.searchParams.append('client_id', clientId);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', redirectUri);
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('nonce', nonce);

// Public clients only.
authUrl.searchParams.append('code_challenge', challenge);
authUrl.searchParams.append('code_challenge_method', 'S256');

window.location.href = authUrl.toString();

state is required and guards against CSRF. nonce is optional, comes back inside the id_token JWT, and guards against replay. Two more optional parameters matter if you sell across markets. locale sets the language of the login screen, and regional variants like en-GB load the market-specific translations configured for that market and language. region_country takes an ISO 3166-1 Alpha-2 country code and loads market-specific policies, branding and content. login_hint prefills the email field with an address you already hold.

The exchange is a form POST to the discovered token_endpoint.

const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: clientId,
  redirect_uri: redirectUri,
  code,
  code_verifier: verifier, // Public clients only.
});

const res = await fetch(config.token_endpoint, {
  method: 'POST',
  headers: {
    'content-type': 'application/x-www-form-urlencoded',
    // Confidential clients only:
    // Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
  },
  body,
});

const {access_token, expires_in, id_token} = await res.json();

The failure modes are documented and specific, which saves a lot of guessing. A 301 means the wrong shop_id in the POST. A 400 with invalid_grant usually means your base64 code challenge still has = padding, or you did not swap + for - and / for _. A 401 with invalid_client means a bad client_id. A 401 with invalid_token in the www-authenticate header means no origin header, and that origin has to be listed in the Javascript Origins setting. A 403 reading “You do not have permission to access this website” means you sent no user-agent.

Which clients actually get a refresh token?

This one catches people out, and the reference is blunt about it. A refresh_token is only returned to customer account clients configured on the shop, such as a headless or Hydrogen storefront. Apps that authenticate with their own app client ID through the customer_authentication app configuration are public clients using PKCE, they get no refresh_token, and calling the refresh grant returns a 400 with unsupported_grant_type.

Ignore the grant_types_supported list in the OpenID configuration when you are working this out. It describes the authorisation server, not your client, so it lists refresh_token for everyone.

If you do hold a refresh token, renewal is the same POST with grant_type=refresh_token, client_id and refresh_token. If you do not, you renew by repeating the authorisation code flow with prompt=none, which returns a fresh code without showing a login screen while the customer’s session is alive, and returns login_required when it is not. The authorization endpoint cannot be loaded in an iframe, so that renewal needs a top-level page redirect. That rules out refreshing the session in a hidden iframe, which is how a good deal of single-page session code is written.

Querying customer data once you have a token

Send the token raw in the Authorization header. There is no Bearer prefix, which trips up anyone reusing a generic OAuth client. Post to the graphql_api URL from discovery, which resolves to https://{shopDomain}/customer/api/2026-07/graphql on the current stable version.

query CustomerOrders {
  customer {
    id
    firstName
    emailAddress {
      emailAddress
    }
    orders(first: 10, sortKey: PROCESSED_AT, reverse: true) {
      nodes {
        id
        name
        processedAt
        financialStatus
        totalPrice {
          amount
          currencyCode
        }
      }
    }
  }
}

Note emailAddress is an object wrapping a field of the same name, not a string. The API is customer-scoped, so the root query takes no customer ID argument: the token decides whose data comes back.

Budget is generous. Shopify limits each app to 7500 cost points per store and customer, replenishing at 100 or 200 points per second depending on plan. Most fields cost 1 point and most mutations cost 10. If your app is public and published, check the protected customer data requirements before you submit it, because reading name, address, phone or email puts you in Level 2.

Does Hydrogen do any of this for you?

Most of it, which is one of the reasons to pick Hydrogen over a Next.js storefront. createCustomerAccountClient gives you login(), authorize(), logout(), isLoggedIn(), handleAuthStatus(), getAccessToken(), query() and mutate(), and the three routes are one line each: account_.login.ts, account_.authorize.ts and account_.logout.ts.

const customerAccount = createCustomerAccountClient({
  waitUntil: (p) => executionContext.waitUntil(p),
  customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID,
  shopId: env.SHOP_ID,
  request,
  session,
});

One wrinkle to save you an afternoon: the API reference names that variable PUBLIC_CUSTOMER_ACCOUNT_ID, while the Hydrogen guide still shows PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID. Run npx shopify hydrogen link and npx shopify hydrogen env pull and use whatever lands in your .env instead of copying either page.

Checkout is a separate handoff. Set the customerAccessToken on the cart with cartBuyerIdentityUpdate before redirecting to checkoutUrl, or append sso=silent to the checkout URL so it verifies the active session over OIDC. Our walkthrough of building and mutating a Storefront API cart covers the rest of that path.

Build it on discovery endpoints and PKCE from the start, even for a server-rendered storefront where you could get away with a confidential client, because the same code then works when someone asks for a mobile app. If you are on Hydrogen, use the built-in client and do not write the flow by hand. And if you are still shipping customerAccessTokenCreate, treat it as migration work with a deadline attached, because Shopify has said it will announce the final sunset date later in 2026. The one case for waiting is a store that has not moved off legacy customer accounts at all, where the account migration comes first and the API swap follows it.

Whoooop builds headless Shopify storefronts and the customer account flows behind them, including moves off legacy accounts onto the current API. If you want a second opinion on an auth flow before it reaches production, that is the kind of work our Shopify development practice covers.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch