Rotate Legacy Shopify Custom App API Keys Programmatically

Rotate Legacy Shopify Custom App API Keys Programmatically

Introduction

Your security team runs a quarterly credential rotation policy. Your DevOps pipeline requires automated secret management. A legacy custom app API key may have been exposed in a log file, a Git commit, or a third-party service. Whatever the reason, you need to rotate a Shopify legacy custom app API key — and you need to do it programmatically, not by logging into the admin and clicking buttons manually.

Here is the uncomfortable truth you need to hear before going further:

Shopify does not provide a public API endpoint for rotating legacy custom app API keys or access tokens.

There is no POST /admin/api/credentials/rotate endpoint. There is no GraphQL mutation for it. This is a deliberate security architecture decision — credential issuance and rotation is handled through Shopify's admin UI, not through the API surface itself.

However, this doesn't mean you're out of options. This guide covers what is actually possible, the precise limits of each approach, the security patterns you can implement around these constraints, and — critically — the migration path that eliminates the problem entirely by moving off legacy custom apps.


Understanding the Credential Types

Before diagnosing your rotation options, identify exactly which credential type you're working with. "Legacy custom app API key" covers several distinct credential types with different rotation behaviors.


Type 1 — Legacy Custom App Admin API Key + Secret

Created through Shopify Admin → Settings → Apps → Develop apps (legacy). These credentials consist of:

  • API key (public identifier — like a username)
  • API secret key (private — like a password)
  • Admin API access token (the actual bearer token used in API calls)

The access token is generated once when the app is installed and does not expire. The API key and secret are the app's identity credentials.

Rotation behavior:

  • Access tokens cannot be rotated via API
  • API key and secret cannot be changed — they are permanent for the lifetime of the app
  • The only way to get new credentials is to delete and recreate the app (which changes everything)

Type 2 — Private App Access Token (Pre-2022)

Created before Shopify deprecated private apps in January 2022. These are permanent, non-expiring tokens tied to a single store.

Rotation behavior:

  • Cannot be rotated via API
  • Cannot be rotated via admin UI (the private app system is fully deprecated)
  • The only path forward is migration to a custom app

Type 3 — Custom App Access Token (Modern)

Created through the current Shopify Admin → Settings → Apps → Develop apps workflow. These are the replacement for both legacy custom apps and private apps.

Rotation behavior:

  • Access tokens cannot be rotated via API
  • Can be uninstalled and reinstalled to generate a new token
  • The underlying API key and secret remain the same across reinstalls

Type 4 — Public App OAuth Tokens

Standard OAuth tokens issued to public/unlisted apps through the Partner Dashboard OAuth flow.

Rotation behavior:

  • These can be rotated programmatically using Shopify's Access Token Rotation feature
  • Covered in detail in the Migration section of this guide

What IS Actually Possible Programmatically

Despite the core limitation, there are several things you can do programmatically around credential management:


1. Verify Token Validity

Before rotating, confirm whether your existing token is still valid:


async function verifyToken(shopDomain, accessToken) {
  try {
    const response = await fetch(
      `https://${shopDomain}/admin/api/2024-10/shop.json`,
      {
        headers: {
          'X-Shopify-Access-Token': accessToken,
          'Content-Type': 'application/json'
        }
      }
    );

    if (response.status === 200) {
      const data = await response.json();
      return {
        valid: true,
        shop: data.shop.name,
        plan: data.shop.plan_name
      };
    }

    if (response.status === 401) {
      return { valid: false, reason: 'Token invalid or revoked' };
    }

    if (response.status === 403) {
      return { valid: false, reason: 'Token lacks required permissions' };
    }

    return { valid: false, reason: `Unexpected status: ${response.status}` };

  } catch (error) {
    return { valid: false, reason: error.message };
  }
}

// Usage
const result = await verifyToken('yourstore.myshopify.com', process.env.SHOPIFY_TOKEN);
console.log(result);
// { valid: true, shop: 'Your Store', plan: 'shopify' }

2. Audit Token Scope Coverage

Programmatically verify which API scopes your current token has access to — useful before a rotation to document what the replacement token must cover:


async function auditTokenScopes(shopDomain, accessToken) {
  // Use the GraphQL Admin API to inspect app installation
  const query = `
    {
      currentAppInstallation {
        accessScopes {
          handle
          description
        }
      }
    }
  `;

  const response = await fetch(
    `https://${shopDomain}/admin/api/2024-10/graphql.json`,
    {
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': accessToken,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ query })
    }
  );

  const data = await response.json();
  return data.data?.currentAppInstallation?.accessScopes ?? [];
}

// Usage
const scopes = await auditTokenScopes('yourstore.myshopify.com', process.env.SHOPIFY_TOKEN);
scopes.forEach(scope => console.log(`${scope.handle}: ${scope.description}`));

3. Detect Token Compromise Signals

Build automated monitoring that detects signs of token misuse — enabling you to respond to compromise before formal rotation:


class TokenHealthMonitor {
  constructor(shopDomain, accessToken) {
    this.shopDomain = shopDomain;
    this.accessToken = accessToken;
    this.baseUrl = `https://${shopDomain}/admin/api/2024-10`;
  }

  async checkRateLimit() {
    // Make a lightweight request and inspect rate limit headers
    const response = await fetch(`${this.baseUrl}/shop.json`, {
      headers: { 'X-Shopify-Access-Token': this.accessToken }
    });

    return {
      callsRemaining: response.headers.get('X-Shopify-Shop-Api-Call-Limit'),
      retryAfter: response.headers.get('Retry-After'),
      // Unexpectedly high usage may indicate token theft
      suspiciousUsage: this.detectAbnormalUsage(
        response.headers.get('X-Shopify-Shop-Api-Call-Limit')
      )
    };
  }

  detectAbnormalUsage(callLimitHeader) {
    if (!callLimitHeader) return false;
    const [used, total] = callLimitHeader.split('/').map(Number);
    const utilizationRate = used / total;
    // Alert if over 80% of rate limit consumed (possible unauthorized use)
    return utilizationRate > 0.8;
  }

  async runHealthCheck() {
    const validity = await verifyToken(this.shopDomain, this.accessToken);
    const rateLimit = await this.checkRateLimit();

    return {
      timestamp: new Date().toISOString(),
      tokenValid: validity.valid,
      rateLimitStatus: rateLimit,
      alertRequired: !validity.valid || rateLimit.suspiciousUsage
    };
  }
}

// Run as a scheduled job (every 5 minutes via cron)
const monitor = new TokenHealthMonitor(
  process.env.SHOPIFY_DOMAIN,
  process.env.SHOPIFY_TOKEN
);

const health = await monitor.runHealthCheck();
if (health.alertRequired) {
  await sendSecurityAlert(health);
}

4. Automate Secret Storage and Distribution

Even though you can't rotate the token itself programmatically, you can automate the storage and distribution of credentials once rotated — minimizing human error in the process:


// AWS Secrets Manager integration
const { SecretsManagerClient, UpdateSecretCommand } = require('@aws-sdk/client-secrets-manager');

async function storeRotatedCredentials(secretName, newToken, metadata) {
  const client = new SecretsManagerClient({ region: process.env.AWS_REGION });

  const secretValue = JSON.stringify({
    shopify_access_token: newToken,
    shop_domain: metadata.shopDomain,
    rotated_at: new Date().toISOString(),
    rotated_by: metadata.rotatedBy,
    scopes: metadata.scopes
  });

  await client.send(new UpdateSecretCommand({
    SecretId: secretName,
    SecretString: secretValue,
    Description: `Shopify token for ${metadata.shopDomain} — rotated ${new Date().toISOString()}`
  }));

  console.log(`Credentials stored in ${secretName}`);
}

// HashiCorp Vault integration
async function storeInVault(path, newToken, metadata) {
  const response = await fetch(`${process.env.VAULT_ADDR}/v1/${path}`, {
    method: 'POST',
    headers: {
      'X-Vault-Token': process.env.VAULT_TOKEN,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        shopify_access_token: newToken,
        shop_domain: metadata.shopDomain,
        rotated_at: new Date().toISOString(),
        scopes: metadata.scopes
      }
    })
  });

  return response.ok;
}

The Manual Rotation Process (What You Must Do Today)

Since programmatic rotation isn't available for legacy custom apps, here is the exact manual process — documented precisely so you can build automation around the steps that are automatable:


Steps to rotate by reinstalling:

  1. Document current configuration (automate this)


    // Before rotating — capture current scopes and settings
    const currentScopes = await auditTokenScopes(shopDomain, currentToken);
    const config = { scopes: currentScopes, rotationDate: new Date() };
    await saveRotationRecord(config);
    
  2. Go to Shopify Admin → Settings → Apps and sales channels

  3. Click "Develop apps" → find your custom app

  4. Click "Uninstall app"

    • This revokes the current access token immediately
    • All API calls using the old token will return 401 from this point
  5. Click "Install app" on the same app

    • A new access token is generated
    • The API key and secret remain the same
  6. Copy the new access token — it is shown only once

  7. Update all systems with the new token (automate this)


    // After rotation — distribute the new token automatically
    await storeRotatedCredentials('shopify/prod/token', newToken, {
      shopDomain,
      rotatedBy: 'security-team',
      scopes: currentScopes
    });
    
    // Restart services that use the token
    await restartDependentServices();
    
    // Verify new token works
    const verification = await verifyToken(shopDomain, newToken);
    console.log('New token valid:', verification.valid);
    

Rotation Runbook Template

Document this as a formal runbook for your team:

Showing rendered markdown

Shopify Custom App Token Rotation Runbook

Pre-rotation checklist

  • Identify all systems using the current token
  • Schedule maintenance window (token rotation causes ~2-5 min of API downtime)
  • Notify dependent teams
  • Run auditTokenScopes() and save output
  • Verify AWS Secrets Manager / Vault access for new token storage

Rotation steps (manual — ~10 minutes)

  • Log in to Shopify Admin as store owner
  • Navigate to Settings → Apps → Develop apps
  • Locate [APP NAME]
  • Click Uninstall — confirm that API calls begin returning 401
  • Click Install — confirm new token is displayed
  • Copy new token to secure clipboard (1Password, etc.)
  • Run storeRotatedCredentials() with new token
  • Verify token with verifyToken()

Post-rotation checklist

  • All dependent services restarted with new token
  • API calls returning 200 from all services
  • Old token confirmed invalid (verifyToken returns false)
  • Rotation event logged in audit trail
  • Next rotation date scheduled

Rollback plan

There is no rollback — once uninstalled, the old token is permanently revoked. Ensure all systems are updated before confirming rotation success.


The Right Solution: Migrate to OAuth Token Rotation

The permanent fix to the programmatic rotation problem is migrating from a legacy custom app to a public or custom app with OAuth — which supports programmatic token rotation natively.


Shopify's Access Token Rotation API

Shopify provides a rotation mechanism for OAuth-based apps via the accessTokenRotate GraphQL mutation:


mutation {
  accessTokenRotate {
    accessToken {
      token
      expiresAt
    }
    userErrors {
      field
      message
    }
  }
}

Requirements for using this mutation:

  • The app must be a public app or custom app installed via OAuth
  • Token rotation must be enabled in the Partner Dashboard for the app
  • The request must be authenticated with the current valid token
  • Rotation generates a new token with a configurable overlap period

Enabling Token Rotation in Partner Dashboard

  1. Go to Partner Dashboard → Apps → [Your App]
  2. Click App setup
  3. Scroll to Event subscriptions or API credentials
  4. Enable Access token rotation
  5. Configure the rotation overlap period (how long the old token remains valid after rotation — typically 60 seconds to 24 hours)
  6. Save

Implementing Automated Token Rotation


class ShopifyTokenRotationService {
  constructor(config) {
    this.shopDomain = config.shopDomain;
    this.currentToken = config.initialToken;
    this.secretsPath = config.secretsPath;
    this.rotationIntervalDays = config.rotationIntervalDays ?? 30;
  }

  async rotateToken() {
    console.log(`[TokenRotation] Starting rotation for ${this.shopDomain}`);

    const mutation = `
      mutation AccessTokenRotate {
        accessTokenRotate {
          accessToken {
            token
            expiresAt
          }
          userErrors {
            field
            message
            code
          }
        }
      }
    `;

    const response = await fetch(
      `https://${this.shopDomain}/admin/api/2024-10/graphql.json`,
      {
        method: 'POST',
        headers: {
          'X-Shopify-Access-Token': this.currentToken,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ query: mutation })
      }
    );

    const result = await response.json();

    // Check for errors
    const errors = result.data?.accessTokenRotate?.userErrors;
    if (errors && errors.length > 0) {
      throw new Error(`Token rotation failed: ${JSON.stringify(errors)}`);
    }

    const newToken = result.data?.accessTokenRotate?.accessToken?.token;
    const expiresAt = result.data?.accessTokenRotate?.accessToken?.expiresAt;

    if (!newToken) {
      throw new Error('Token rotation returned no token');
    }

    // Store new token
    await this.persistNewToken(newToken, expiresAt);

    // Update in-memory token
    this.currentToken = newToken;

    console.log(`[TokenRotation] Rotation successful. New token expires: ${expiresAt}`);
    return { newToken, expiresAt };
  }

  async persistNewToken(token, expiresAt) {
    // Store in your secrets manager of choice
    await storeRotatedCredentials(this.secretsPath, token, {
      shopDomain: this.shopDomain,
      expiresAt,
      rotatedAt: new Date().toISOString(),
      rotatedBy: 'automated-rotation-service'
    });

    // Notify dependent services via event bus
    await this.notifyServicesOfRotation(token);
  }

  async notifyServicesOfRotation(newToken) {
    // Publish to your internal event bus / message queue
    // Services subscribe and update their in-memory token
    await eventBus.publish('shopify.token.rotated', {
      shopDomain: this.shopDomain,
      // Don't include the actual token in the event
      // Services should fetch from Secrets Manager
      secretsPath: this.secretsPath,
      rotatedAt: new Date().toISOString()
    });
  }

  async scheduleRotation() {
    // Run rotation on a schedule
    const intervalMs = this.rotationIntervalDays * 24 * 60 * 60 * 1000;

    setInterval(async () => {
      try {
        await this.rotateToken();
      } catch (error) {
        console.error('[TokenRotation] Rotation failed:', error);
        await sendSecurityAlert({
          type: 'TOKEN_ROTATION_FAILURE',
          shop: this.shopDomain,
          error: error.message
        });
      }
    }, intervalMs);
  }
}

// Initialize rotation service
const rotationService = new ShopifyTokenRotationService({
  shopDomain: process.env.SHOPIFY_DOMAIN,
  initialToken: process.env.SHOPIFY_TOKEN,
  secretsPath: 'shopify/prod/token',
  rotationIntervalDays: 30
});

await rotationService.scheduleRotation();

Handling the Overlap Period

During token rotation, there's an overlap window where both old and new tokens are valid. Use this to achieve zero-downtime rotation:


async function zeroDowntimeRotation(rotationService) {
  // Step 1: Rotate and get new token (old token still valid)
  const { newToken, expiresAt } = await rotationService.rotateToken();

  // Step 2: Update secrets store with new token
  await updateSecretsStore(newToken);

  // Step 3: Gradually shift services to new token
  // (overlap period keeps old token alive during transition)
  await notifyServicesGradually(newToken, {
    batchSize: 5,       // Update 5 services at a time
    delayBetweenBatches: 5000  // 5 seconds between batches
  });

  // Step 4: Verify all services using new token
  const verificationResults = await verifyAllServices();

  if (verificationResults.every(r => r.success)) {
    console.log('All services successfully using new token');
  } else {
    const failures = verificationResults.filter(r => !r.success);
    console.error('Some services failed to update:', failures);
    await sendAlert(failures);
  }
}

Migration Path: Legacy Custom App → OAuth App

If you're on a legacy custom app and want programmatic rotation, here is the migration path:


Phase 1 — Assessment (Week 1)


// Audit all API calls your legacy app makes
async function auditApiUsage(shopDomain, accessToken) {
  const scopes = await auditTokenScopes(shopDomain, accessToken);

  return {
    currentScopes: scopes.map(s => s.handle),
    apiVersion: '2024-10',    // Document current version
    estimatedCallVolume: 'HIGH/MEDIUM/LOW',  // From your logs
    criticalEndpoints: [
      // List endpoints your app cannot function without
      'orders', 'products', 'inventory_levels'
    ]
  };
}

Phase 2 — Create New Custom App (Week 1–2)

  1. Go to Shopify Admin → Settings → Apps → Develop apps
  2. Click Create an app
  3. Configure identical API scopes to your legacy app
  4. Install the app to generate a new token
  5. Enable Access token rotation in Partner Dashboard

Phase 3 — Parallel Running (Week 2–3)

Run both the legacy and new app simultaneously:


// Dual-token middleware — test new token alongside legacy
async function dualTokenRequest(endpoint, legacyToken, newToken) {
  // Make request with new token
  const newResponse = await fetch(endpoint, {
    headers: { 'X-Shopify-Access-Token': newToken }
  });

  // Compare responses
  const legacyResponse = await fetch(endpoint, {
    headers: { 'X-Shopify-Access-Token': legacyToken }
  });

  if (newResponse.status !== legacyResponse.status) {
    console.warn(`Response mismatch: legacy=${legacyResponse.status}, new=${newResponse.status}`);
  }

  // Return new token response (if valid) else fall back
  return newResponse.ok ? newResponse : legacyResponse;
}

Phase 4 — Cutover (Week 3–4)

  1. Update all services to use the new token exclusively
  2. Verify all API calls succeed with new token
  3. Enable automated rotation on the new app
  4. Uninstall the legacy custom app
  5. Update your secrets manager with rotation schedule

Security Best Practices Regardless of Approach

Never store tokens in:

  • Source code or config files committed to Git
  • Environment variables in plaintext CI/CD logs
  • Application database without encryption
  • Slack messages, emails, or other communication tools

Always store tokens in:

  • AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault
  • Encrypted environment variables in your deployment platform
  • Rotated regularly on a documented schedule

Token access patterns:


// ❌ Wrong — hardcoded in source
const token = 'shpat_abc123...';

// ❌ Wrong — plaintext env var in code
const token = process.env.SHOPIFY_TOKEN; // Fine if env is secured

// ✅ Right — fetched from secrets manager at runtime
async function getToken() {
  const secret = await secretsManager.getSecretValue({
    SecretId: 'shopify/prod/access-token'
  });
  return JSON.parse(secret.SecretString).shopify_access_token;
}

// ✅ Right — cached with TTL, refreshed from Vault
const tokenCache = new TTLCache({ ttl: 300_000 }); // 5 min TTL

async function getCachedToken() {
  if (!tokenCache.has('shopify_token')) {
    const token = await getToken();
    tokenCache.set('shopify_token', token);
  }
  return tokenCache.get('shopify_token');
}

Summary

Credential Type Programmatic Rotation Method
Legacy custom app token ❌ Not possible Manual uninstall/reinstall
Private app token (pre-2022) ❌ Not possible Migrate to custom app
Modern custom app token ⚠️ Partial Manual reinstall + automated distribution
OAuth public app token ✅ Fully supported accessTokenRotate GraphQL mutation
Task Automatable? Tool
Token validity check ✅ Yes REST API GET /shop.json
Scope audit ✅ Yes GraphQL currentAppInstallation
Token storage after manual rotation ✅ Yes AWS Secrets Manager / Vault
Token distribution to services ✅ Yes Event bus + secrets manager
Full programmatic rotation ✅ Yes (OAuth apps only) accessTokenRotate mutation
Legacy app rotation without UI ❌ No Migrate to OAuth app
Back to blog

Leave a comment