Skip to main content

Get application-only access tokens for installed stores

Your app's server can get a GraphQL Admin API access token for a store that has already installed your app, without a merchant session. This is useful for background work that no user is actively driving, such as scheduled syncs and webhook follow-up jobs.

Instead of sending your client secret, your server signs a short-lived JSON Web Token (JWT), called a client assertion, with a private key that only your app holds. Shopify verifies the assertion with a public key that you add in the Dev Dashboard, and returns an access token.

An app with public distribution can use this flow for any store that has installed it, and gets tokens that are valid for up to one hour. Other apps, such as custom apps, keep the client credentials grant rules: they can get tokens only for stores in their organization, and the tokens are valid for 24 hours.


In this tutorial, you'll learn how to do the following tasks:

  • Generate a key pair and keep the private key secure
  • Export the public key as a JSON Web Key (JWK) and add it in the Dev Dashboard
  • Sign a client assertion for a store
  • Exchange the assertion for an access token


Anchor to Step 1: Generate a key pairStep 1: Generate a key pair

Generate a key pair with OpenSSL. Use filenames that don't already exist, because these commands overwrite existing files:

Generate a key pair

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

Keep private.pem secure. Never upload or commit it.

Shopify accepts the following signing algorithms:

Algorithm (alg)Key typeKey size
RS256RSA2048 to 8192 bits
ES256ECP-256

This tutorial uses RSA with RS256. The example code selects the signing algorithm from your key type. In production, keep the private key in a secret manager or key management service (KMS). To learn more, refer to Manage public keys.


Anchor to Step 2: Export the public key as a JWKStep 2: Export the public key as a JWK

The Dev Dashboard accepts a public key as a JWK, not as a PEM file. OpenSSL can't write JWKs, but you can convert the key with the crypto module that's built into Node.js.

  1. Choose a key ID (kid), such as app-only-1. It can be up to 255 characters, can't start or end with whitespace, and can't match the key ID of another active key on your app.

  2. Convert the public key, and add your key ID, the signing algorithm, and the key use. For an EC key, use alg: "ES256" instead of alg: "RS256":

    node -p 'JSON.stringify({...require("crypto").createPublicKey(require("fs").readFileSync("public.pem")).export({format: "jwk"}), kid: "app-only-1", alg: "RS256", use: "sig"}, null, 2)'
  3. Copy the JWK that the command prints. It looks like the following example, with a much longer n value:

    {
    "kty": "RSA",
    "n": "{modulus}",
    "e": "AQAB",
    "kid": "app-only-1",
    "alg": "RS256",
    "use": "sig"
    }

A public RSA JWK contains only kty, n, and e, and a public EC JWK contains only kty, crv, x, and y. Both also contain metadata such as kid, alg, and use.

The Dev Dashboard requires kid and alg, and accepts only sig as the use value. If a JWK contains d, p, q, dp, dq, or qi, then it includes private key material, and the Dev Dashboard rejects it.

If you ever paste a private key anywhere, then treat it as compromised. Generate a new key pair, add the new public key, and revoke the old key.


Anchor to Step 3: Add the public key in the Dev DashboardStep 3: Add the public key in the Dev Dashboard

Add the JWK to your app so that Shopify can verify assertions that you sign with the matching private key.

  1. Open your app in the Dev Dashboard.
  2. Go to Settings.
  3. In the Credentials section, under Public keys, click Add key.
  4. In the Add public key dialog, paste the JWK from Step 2 into the Public key field.
  5. Click Add key.

Anchor to Step 4: Create a client assertionStep 4: Create a client assertion

A client assertion proves your app's identity for one token request to one store. It isn't an access token, so never send it to the GraphQL Admin API. ID tokens are a different credential, and don't work in its place.

  1. Set the environment variables that the example reads. Replace the placeholders with your app's client ID from Settings and the path to your private key. Use the key ID from the JWK you added in Step 3.

    export SHOPIFY_CLIENT_ID="{your-client-id}"
    export SHOPIFY_KEY_ID="app-only-1"
    export SHOPIFY_PRIVATE_KEY_PATH="/path/to/private.pem"

    These settings are the same for every store. The store isn't part of this configuration: you pass its domain with each token request. This flow doesn't use your client secret, so don't add it.

  2. Review how createClientAssertion in app-only-token.js signs the assertion:

    app-only-token.js

    // Sign a new assertion for every token request, including retries.
    export async function createClientAssertion({clientId, keyId, privateKey}, shopDomain) {
    assertShopDomain(shopDomain);
    const now = Math.floor(Date.now() / 1000);
    let alg;
    if (privateKey.asymmetricKeyType === 'rsa') {
    alg = 'RS256';
    } else if (privateKey.asymmetricKeyType === 'ec' && privateKey.asymmetricKeyDetails.namedCurve === 'prime256v1') {
    alg = 'ES256';
    } else {
    throw new Error('Use an RSA or P-256 EC private key.');
    }

    // Header: {alg: 'RS256', kid: 'app-only-1'} (ES256 for P-256).
    // Payload: {iss: 'client-id', sub: 'client-id', aud: 'https://your-store.myshopify.com/admin/oauth/access_token',
    // iat: 1792531200, exp: 1792531260, jti: 'a-new-random-uuid'}.
    return new SignJWT({})
    .setProtectedHeader({alg, kid: keyId})
    .setIssuer(clientId)
    .setSubject(clientId)
    .setAudience(`https://${shopDomain}/admin/oauth/access_token`)
    .setIssuedAt(now)
    .setExpirationTime(now + ASSERTION_LIFETIME_SECONDS)
    .setJti(randomUUID())
    .sign(privateKey);
    }

Shopify checks the following parts of the assertion:

PartValue
Header algRS256 or ES256, matching the key that you added
Header kidThe key ID of an active public key on your app
iss and subYour app's client ID
audhttps://{shop}.myshopify.com/admin/oauth/access_token, as a single string with the store's permanent domain
iatThe current time, in whole seconds since the Unix epoch
expThe expiry time in whole seconds, no more than 300 seconds after iat. The example uses 60 seconds.
jtiA unique value for every assertion. The example uses a random UUID.

Shopify accepts each jti only once for your app, across all stores. Sign a new assertion for every token request, including every retry, even if the request that used the previous assertion failed.


Anchor to Step 5: Request an access tokenStep 5: Request an access token

Send the assertion to the store's token endpoint with the client_credentials grant. The first part of requestAccessToken in app-only-token.js signs a new assertion and sends the request:

app-only-token.js

export async function requestAccessToken(config, shopDomain) {
const clientAssertion = await createClientAssertion(config, shopDomain);
const response = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_assertion_type: CLIENT_ASSERTION_TYPE,
client_assertion: clientAssertion,
}),
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

A few things to know about this request:

  • CLIENT_ASSERTION_TYPE is urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
  • Don't send your client secret, an Authorization: Basic header, or a code_verifier in the same request, because Shopify rejects a request that combines more than one way to authenticate.
  • Don't send a scope parameter. Shopify ignores it: the token always carries all the access scopes that the store granted to your app installation, which might be broader than the scopes you send.
  • The example sends Accept: application/json so that errors come back as JSON.

Shopify returns a response like the following example:

{
"access_token": "{access_token}",
"scope": "read_products",
"expires_in": 3599
}
  • access_token: An opaque token to send in the X-Shopify-Access-Token header. Store it securely, and don't log it.
  • scope: The access scopes that the store granted to your app installation, which is what the token carries. It isn't every scope your app declares: an optional scope that the store declined or revoked doesn't appear.
  • expires_in: The number of seconds until the token expires. Read this value instead of assuming a lifetime.

The example caches one token per store and renews it at a random point between three and eight minutes before it expires, so call getAccessToken before each request instead of holding on to a token. The random offset keeps your servers from all requesting new tokens at the same moment. Getting a new token doesn't revoke the current one, so if a renewal fails, then your app can keep using the current token until it expires.

The response doesn't include a refresh token. For 5xx responses, network errors, and timeouts, the example makes up to three attempts in total, with exponential backoff and a newly signed assertion each time. It doesn't retry 4xx responses, because they fail again until you fix the cause.


The complete Node.js example is available below as a reference. It uses Node.js 22 or higher and jose. To try it, save the files together, run npm install in that directory, and then run query-products.js with the store's lowercase permanent myshopify.com domain:

node query-products.js {shop}.myshopify.com

The script gets a token for that store and runs a read-only product query, which requires the read_products scope. In your app, use the domains of the stores that have installed your app. Keep private keys and environment files out of version control.

Complete Node.js example

package.json

{
"name": "shopify-app-only-access-example",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"dependencies": {
"jose": "6.2.13"
}
}

.gitignore

node_modules/
.env
*.pem
*.key

Example files

// Requests application-only GraphQL Admin API access tokens with a signed client assertion.
import {readFileSync} from 'node:fs';
import {createPrivateKey, randomUUID} from 'node:crypto';
import {SignJWT} from 'jose';

const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
const SHOP_DOMAIN_PATTERN = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/;
const ASSERTION_LIFETIME_SECONDS = 60;
// Renew at a random point 3 to 8 minutes before expiry so that processes don't all renew at the same moment.
const RENEW_BEFORE_EXPIRY_MIN_SECONDS = 180;
const RENEW_BEFORE_EXPIRY_JITTER_SECONDS = 300;
const MAX_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 10_000;

export class TokenRequestError extends Error {
constructor(status, error, description) {
super(`Token request failed with HTTP ${status}: ${error ?? 'unknown_error'}${description ? ` (${description})` : ''}`);
this.status = status;
this.error = error;
}
}

export function loadConfigFromEnv(env = process.env) {
const missing = ['SHOPIFY_CLIENT_ID', 'SHOPIFY_KEY_ID', 'SHOPIFY_PRIVATE_KEY_PATH'].filter((name) => !env[name]);
if (missing.length > 0) {
throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}

// In production, load the key from your secret manager instead of the file system.
const privateKey = createPrivateKey(readFileSync(env.SHOPIFY_PRIVATE_KEY_PATH, 'utf8'));
return {clientId: env.SHOPIFY_CLIENT_ID, keyId: env.SHOPIFY_KEY_ID, privateKey};
}

export function assertShopDomain(shopDomain) {
if (!SHOP_DOMAIN_PATTERN.test(shopDomain ?? '')) {
throw new Error('Use the shop\'s exact, lowercase permanent myshopify.com domain, for example your-store.myshopify.com.');
}
}

// Sign a new assertion for every token request, including retries.
export async function createClientAssertion({clientId, keyId, privateKey}, shopDomain) {
assertShopDomain(shopDomain);
const now = Math.floor(Date.now() / 1000);
let alg;
if (privateKey.asymmetricKeyType === 'rsa') {
alg = 'RS256';
} else if (privateKey.asymmetricKeyType === 'ec' && privateKey.asymmetricKeyDetails.namedCurve === 'prime256v1') {
alg = 'ES256';
} else {
throw new Error('Use an RSA or P-256 EC private key.');
}

// Header: {alg: 'RS256', kid: 'app-only-1'} (ES256 for P-256).
// Payload: {iss: 'client-id', sub: 'client-id', aud: 'https://your-store.myshopify.com/admin/oauth/access_token',
// iat: 1792531200, exp: 1792531260, jti: 'a-new-random-uuid'}.
return new SignJWT({})
.setProtectedHeader({alg, kid: keyId})
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(`https://${shopDomain}/admin/oauth/access_token`)
.setIssuedAt(now)
.setExpirationTime(now + ASSERTION_LIFETIME_SECONDS)
.setJti(randomUUID())
.sign(privateKey);
}

export async function requestAccessToken(config, shopDomain) {
const clientAssertion = await createClientAssertion(config, shopDomain);
const response = await fetch(`https://${shopDomain}/admin/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_assertion_type: CLIENT_ASSERTION_TYPE,
client_assertion: clientAssertion,
}),
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

const payload = await response.json().catch((error) => {
if (error instanceof SyntaxError) return null;
throw error;
});

if (!response.ok) {
throw new TokenRequestError(response.status, payload?.error, payload?.error_description);
}

if (typeof payload?.access_token !== 'string' || !Number.isInteger(payload?.expires_in)) {
throw new TokenRequestError(response.status, 'unexpected_response');
}

const obtainedAt = Date.now();
const renewBeforeSeconds = RENEW_BEFORE_EXPIRY_MIN_SECONDS + Math.random() * RENEW_BEFORE_EXPIRY_JITTER_SECONDS;
const renewAfterSeconds = Math.max(payload.expires_in - renewBeforeSeconds, payload.expires_in / 2);

return {
accessToken: payload.access_token,
scope: payload.scope,
expiresAt: obtainedAt + payload.expires_in * 1000,
renewAt: obtainedAt + renewAfterSeconds * 1000,
};
}

function isRetryable(error) {
if (error instanceof TokenRequestError) {
return error.status >= 500;
}
// Network failures and timeouts.
return error.name === 'TypeError' || error.name === 'TimeoutError' || error.name === 'AbortError';
}

async function requestAccessTokenWithRetry(config, shopDomain) {
for (let attempt = 1; ; attempt++) {
try {
return await requestAccessToken(config, shopDomain);
} catch (error) {
if (attempt >= MAX_ATTEMPTS || !isRetryable(error)) {
throw error;
}
const delayMs = 2 ** (attempt - 1) * 1000 + Math.floor(Math.random() * 250);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}

// Cache is per process. Replace it with shared storage if several processes call the same shop.
const tokens = new Map();
const pendingRequests = new Map();

export async function getAccessToken(config, shopDomain) {
const cached = tokens.get(shopDomain);
if (cached && Date.now() < cached.renewAt) {
return cached;
}

let pending = pendingRequests.get(shopDomain);
if (!pending) {
pending = requestAccessTokenWithRetry(config, shopDomain)
.then((token) => {
tokens.set(shopDomain, token);
return token;
})
.finally(() => pendingRequests.delete(shopDomain));
pendingRequests.set(shopDomain, pending);
}

return pending;
}

// Call this when the GraphQL Admin API returns 401, then request a new token.
export function invalidateAccessToken(shopDomain) {
tokens.delete(shopDomain);
}
// Gets an application-only access token for one shop and runs a read-only GraphQL Admin API query.
// Usage: node query-products.js {shop}.myshopify.com
// Requires the read_products access scope on the app's installation.
import {assertShopDomain, getAccessToken, invalidateAccessToken, loadConfigFromEnv} from './app-only-token.js';

const API_VERSION = '2026-10';

async function adminGraphql(config, shopDomain, query) {
for (let attempt = 0; attempt < 2; attempt++) {
const token = await getAccessToken(config, shopDomain);
const response = await fetch(`https://${shopDomain}/admin/api/${API_VERSION}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': token.accessToken,
},
body: JSON.stringify({query}),
});

if (response.status === 401) {
invalidateAccessToken(shopDomain);
if (attempt === 0) continue;
throw new Error('The access token was rejected again. Stop and investigate before retrying.');
}

const result = await response.json();
if (!response.ok || result.errors) {
throw new Error(`GraphQL Admin API request failed: ${JSON.stringify(result.errors ?? response.status)}`);
}
return result;
}
}

const shopDomain = process.argv[2];
assertShopDomain(shopDomain);
const config = loadConfigFromEnv();
const result = await adminGraphql(config, shopDomain, `#graphql
query {
products(first: 3) {
nodes {
id
title
}
}
}`);

for (const product of result.data.products.nodes) {
console.log(`${product.id} ${product.title}`);
}


Was this page helpful?