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.
Anchor to What you'll learnWhat you'll learn
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 RequirementsRequirements
- Create an app in the Dev Dashboard.
- Configure the access scopes your app needs.
- Install your app on a store and approve the requested access scopes.
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
RS256
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem
openssl pkey -in private.pem -pubout -out public.pemES256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem
openssl pkey -in private.pem -pubout -out public.pemKeep private.pem secure. Never upload or commit it.
Shopify accepts the following signing algorithms:
Algorithm (alg) | Key type | Key size |
|---|---|---|
RS256 | RSA | 2048 to 8192 bits |
ES256 | EC | P-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.
-
Choose a key ID (
kid), such asapp-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. -
Convert the public key, and add your key ID, the signing algorithm, and the key use. For an EC key, use
alg: "ES256"instead ofalg: "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)' -
Copy the JWK that the command prints. It looks like the following example, with a much longer
nvalue:{"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.
- Open your app in the Dev Dashboard.
- Go to Settings.
- In the Credentials section, under Public keys, click Add key.
- In the Add public key dialog, paste the JWK from Step 2 into the Public key field.
- 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.
-
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.
-
Review how
createClientAssertioninapp-only-token.jssigns 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:
| Part | Value |
|---|---|
Header alg | RS256 or ES256, matching the key that you added |
Header kid | The key ID of an active public key on your app |
iss and sub | Your app's client ID |
aud | https://{shop}.myshopify.com/admin/oauth/access_token, as a single string with the store's permanent domain |
iat | The current time, in whole seconds since the Unix epoch |
exp | The expiry time in whole seconds, no more than 300 seconds after iat. The example uses 60 seconds. |
jti | A 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
A few things to know about this request:
CLIENT_ASSERTION_TYPEisurn:ietf:params:oauth:client-assertion-type:jwt-bearer.- Don't send your client secret, an
Authorization: Basicheader, or acode_verifierin the same request, because Shopify rejects a request that combines more than one way to authenticate. - Don't send a
scopeparameter. 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/jsonso that errors come back as JSON.
Shopify returns a response like the following example:
access_token: An opaque token to send in theX-Shopify-Access-Tokenheader. 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.
Anchor to Example codeExample code
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:
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
.gitignore
Example files
app-only-token.js
// 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);
}query-products.js
// 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}`);
}Anchor to Next stepsNext steps
- Manage public keys: Rotate keys, respond to a compromised key, and sign assertions with a KMS.
- Access tokens: Compare access token types, lifetimes, and grants.
- Manage access scopes: Learn how merchants approve the scopes that your app's tokens carry.