How to Get Shopify Access Tokens in 2026: Complete OAuth Guide for Developers
Learn how to get Shopify access tokens using OAuth in 2026. Complete guide covering Authorization Code, Client Credentials, and Token Exchange flows with TypeScript examples.
How to Get Shopify Access Tokens in 2026: Complete OAuth Guide for Developers
If you're searching for how to copy an access token from Shopify Admin, that feature no longer exists. Shopify has deprecated the old Custom App token workflow that developers relied on for years. The simple "API Credentials" tab where you could generate and copy tokens directly from the admin interface is gone.
This isn't just an inconvenience—it's a fundamental shift in how Shopify apps authenticate. The good news? The new OAuth-based approach is more secure and gives you more flexibility. The bad news? There's a learning curve, and most tutorials online are outdated.
In this comprehensive guide, I'll walk you through everything you need to know about getting Shopify access tokens in 2026, including:
- Why Shopify deprecated static tokens
- The three OAuth grant types and when to use each
- Step-by-step implementation with production-ready TypeScript code
- Security best practices from our own production apps
- Real-world integration patterns for desktop applications
Table of Contents
- What Changed and Why
- Understanding Shopify OAuth in 2026
- The Three OAuth Grant Types
- Setting Up Your Shopify App
- Authorization Code Flow Implementation
- Client Credentials Flow Implementation
- Token Exchange for Embedded Apps
- Security Best Practices
- Real-World Example: Desktop App Integration
- Troubleshooting Common OAuth Errors
- Migration Checklist
- FAQ
What Changed and Why
The Old Way (Deprecated)
Previously, getting a Shopify access token was simple:
- Go to Shopify Admin → Settings → Apps and sales channels
- Click "Develop apps" → Create an app
- Configure API scopes
- Click "Install app"
- Copy the access token from the "API credentials" tab
This token never expired and worked indefinitely. Easy, but fundamentally insecure.
Why Shopify Made This Change
Static tokens posed several security risks:
- No revocation without reinstallation - If a token leaked, you couldn't rotate it without uninstalling the app
- No audit trail - No way to track when or how tokens were used
- Credential sprawl - Tokens often ended up in codebases, config files, and Slack messages
- No granular control - Once issued, tokens had full access to all requested scopes forever
The new OAuth approach addresses these issues with proper token lifecycle management, refresh capabilities, and better security controls.
What This Guide Covers
This guide focuses on getting access tokens for:
- Custom apps for your own stores
- Public apps distributed through the Shopify App Store
- Desktop applications that need to sync with Shopify
If you're building an embedded Shopify app from scratch, I recommend starting with the official Shopify app template ↗ which handles OAuth automatically.
Understanding Shopify OAuth in 2026
Where to Create Your App
Shopify apps are now managed in the Shopify Partners Dashboard or through Organization Apps for businesses with multiple stores:
- Go to partners.shopify.com ↗
- Navigate to Apps in the left sidebar
- Click Create app
- Choose your distribution method:
- Public - Listed on the Shopify App Store
- Custom - For specific stores (your own or clients')
Key OAuth Concepts
Client ID and Client Secret
When you create an app, Shopify generates a client_id (sometimes called API Key) and client_secret (sometimes called API Secret Key). These are your app's credentials:
client_id: Public identifier for your appclient_secret: Private key that must never be exposed client-side
Redirect URIs
For the Authorization Code flow, you must register allowed redirect URIs. Shopify will only redirect users to these pre-approved URLs after they authorize your app.
Scopes
Scopes define what your app can access. Request only what you need:
read_products write_products # Product access
read_orders write_orders # Order access
read_inventory write_inventory # Inventory management
read_customers write_customers # Customer data
The Three OAuth Grant Types
Shopify supports three OAuth flows. Here's when to use each:
| Grant Type | Use Case | Token Expiry | User Interaction |
|---|---|---|---|
| Authorization Code | Public apps, user-authorized access | Never expires | Yes - user consent required |
| Client Credentials | Your own stores, machine-to-machine | 24 hours | No - automatic |
| Token Exchange | Embedded apps with App Bridge | Short-lived | No - uses session tokens |
Decision Tree
Do you need to access stores you don't own?
├── YES → Authorization Code Grant
│ └── User must authorize via browser
│
└── NO → Is this an embedded Shopify app?
├── YES → Token Exchange Grant
│ └── Uses App Bridge session tokens
│
└── NO → Client Credentials Grant
└── Direct API access to your store
Quick Comparison
Authorization Code Grant
- Best for: Public apps, apps installed by merchants
- Pros: Tokens don't expire, user explicitly approves access
- Cons: Requires browser interaction, more complex to implement
Client Credentials Grant
- Best for: Internal tools, desktop apps for your own store
- Pros: No user interaction needed, simple to implement
- Cons: Only works on stores where the app is installed, tokens expire in 24h
Token Exchange Grant
- Best for: Embedded apps running inside Shopify Admin
- Pros: Seamless UX, no redirects needed
- Cons: Only works in embedded context, requires App Bridge
Setting Up Your Shopify App
Before implementing any OAuth flow, you need to create and configure your app:
Step 1: Create the App
- Go to Shopify Partners Dashboard ↗ → Apps → Create app
- Choose Create app manually (not "Create app with Remix template" unless you want the full framework)
- Enter your app name
- Configure distribution:
- For testing: Choose "Custom" distribution
- For production: Choose "Public" if you plan to list on the App Store
Step 2: Configure OAuth Settings
Navigate to your app's configuration:
- App Setup → Configuration
- Set your App URL (where users land after installation)
- Add Allowed redirection URL(s):
- For local development:
https://your-ngrok-url.ngrok.io/api/shopify/auth/callback - For production:
https://yourdomain.com/api/shopify/auth/callback
- For local development:
Step 3: Set API Scopes
Under API access:
- Click Configure under Admin API integration
- Select the scopes your app needs
- Save changes
Important: You can always add scopes later, but merchants will need to re-authorize if you add new ones.
Step 4: Store Your Credentials Securely
After configuration, you'll have:
- Client ID (API Key)
- Client Secret (API Secret Key)
Never commit these to source control. Use environment variables:
# .env
SHOPIFY_CLIENT_ID=your_client_id
SHOPIFY_CLIENT_SECRET=your_client_secret
SHOPIFY_OAUTH_CALLBACK_URL=https://yourdomain.com/api/shopify/auth/callback
Authorization Code Flow Implementation
The Authorization Code flow is the standard OAuth 2.0 flow for user-authorized access. Here's a complete implementation.
Overview
┌──────────────┐ ┌──────────────┐
│ Your App │ │ Shopify │
└──────┬───────┘ └──────┬───────┘
│ │
│ 1. Build Authorization URL │
│ ──────────────────────────────────────────────────>│
│ │
│ 2. User authorizes in browser │
│ <──────────────────────────────────────────────────│
│ │
│ 3. Shopify redirects to callback with code │
│ <──────────────────────────────────────────────────│
│ │
│ 4. Exchange code for access token │
│ ──────────────────────────────────────────────────>│
│ │
│ 5. Receive access token │
│ <──────────────────────────────────────────────────│
│ │
Part 1: Build the Authorization URL
The first step is constructing the URL where users will authorize your app:
// utils/shopify-oauth.ts
interface BuildAuthUrlParams {
shopDomain: string; // e.g., "mystore.myshopify.com"
clientId: string;
redirectUri: string;
state: string; // CSRF protection - random string you verify later
scopes?: string[]; // Optional - uses app's configured scopes if omitted
}
export function buildShopifyAuthUrl({
shopDomain,
clientId,
redirectUri,
state,
scopes,
}: BuildAuthUrlParams): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
state: state,
});
// Only include scope if explicitly provided
// Otherwise, Shopify uses the app's configured scopes
if (scopes && scopes.length > 0) {
params.set("scope", scopes.join(","));
}
return `https://${shopDomain}/admin/oauth/authorize?${params.toString()}`;
}
Important: The state parameter is critical for security. Generate a cryptographically random string, store it in your session, and verify it when Shopify redirects back.
Part 2: Handle the OAuth Callback
After the user authorizes, Shopify redirects to your callback URL with these parameters:
code- The authorization code to exchange for a tokenshop- The shop domain (e.g., mystore.myshopify.com)state- Your state parameter (verify this matches!)hmac- Signature to verify the request is from Shopify
Here's a complete callback handler:
// routes/api.shopify.auth.callback.ts
import crypto from "crypto";
interface ShopifyTokenResponse {
access_token: string;
scope: string;
}
export async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const shop = url.searchParams.get("shop");
const state = url.searchParams.get("state");
const hmac = url.searchParams.get("hmac");
// 1. Validate required parameters
if (!code || !shop || !state) {
throw new Error("Missing required parameters from Shopify");
}
// 2. Verify state matches what we stored (CSRF protection)
const storedState = await getStoredState(state); // Your session lookup
if (!storedState) {
throw new Error("Invalid or expired state parameter");
}
// 3. Verify HMAC signature
if (hmac) {
const clientSecret = process.env.SHOPIFY_CLIENT_SECRET!;
const isValid = verifyHmac(url.searchParams, hmac, clientSecret);
if (!isValid) {
throw new Error("HMAC verification failed");
}
}
// 4. Exchange code for access token
const tokenResponse = await exchangeCodeForToken(
shop,
code,
process.env.SHOPIFY_CLIENT_ID!,
process.env.SHOPIFY_CLIENT_SECRET!
);
// 5. Store the token securely
await saveAccessToken(shop, tokenResponse.access_token, tokenResponse.scope);
return { success: true, shop };
}
Part 3: Verify HMAC Signature
Shopify signs all OAuth callbacks with an HMAC. Always verify this to prevent forgery:
/**
* Verify the HMAC signature from Shopify
* Uses timing-safe comparison to prevent timing attacks
*/
function verifyHmac(
params: URLSearchParams,
hmac: string,
clientSecret: string
): boolean {
// Build the message string (sorted params excluding hmac)
const entries = Array.from(params.entries())
.filter(([key]) => key !== "hmac")
.sort(([a], [b]) => a.localeCompare(b));
const message = entries.map(([k, v]) => `${k}=${v}`).join("&");
const calculatedHmac = crypto
.createHmac("sha256", clientSecret)
.update(message)
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(hmac, "hex"),
Buffer.from(calculatedHmac, "hex")
);
} catch {
// If buffers are different lengths, timingSafeEqual throws
return false;
}
}
Why timing-safe comparison matters: A regular string comparison (===) returns faster when strings differ early. An attacker could time responses to guess the correct HMAC one character at a time. crypto.timingSafeEqual always takes the same amount of time regardless of where strings differ.
Part 4: Exchange Code for Token
The authorization code is single-use and expires quickly. Exchange it for an access token immediately:
/**
* Exchange the authorization code for an access token
*/
async function exchangeCodeForToken(
shop: string,
code: string,
clientId: string,
clientSecret: string
): Promise {
const response = await fetch(`https://${shop}/admin/oauth/access_token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Shopify token exchange failed: ${error}`);
}
return response.json() as Promise;
}
Response format:
{
"access_token": "shpua_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"scope": "read_products,write_products,read_orders"
}
Key point: Authorization Code tokens do not expire. Store them securely and they'll work indefinitely (unless the merchant uninstalls your app or revokes access).
Client Credentials Flow Implementation
The Client Credentials flow is simpler but only works for stores where your app is already installed. It's ideal for:
- Desktop applications syncing with your own stores
- Background jobs and cron tasks
- Internal tools and scripts
Overview
┌──────────────┐ ┌──────────────┐
│ Your App │ │ Shopify │
└──────┬───────┘ └──────┬───────┘
│ │
│ POST /admin/oauth/access_token │
│ grant_type=client_credentials │
│ ──────────────────────────────────────────────────>│
│ │
│ { access_token, expires_in: 86399 } │
│ <──────────────────────────────────────────────────│
│ │
Implementation
interface ClientCredentialsResponse {
access_token: string;
scope: string;
expires_in: number; // Seconds until expiry (typically 86399 = ~24 hours)
}
/**
* Fetch access token using client_credentials grant
* Important: Content-Type must be application/x-www-form-urlencoded
*/
async function fetchClientCredentialsToken(
shopDomain: string,
clientId: string,
clientSecret: string
): Promise {
const response = await fetch(
`https://${shopDomain}/admin/oauth/access_token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}).toString(),
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token request failed: ${response.status} - ${errorText}`);
}
return response.json() as Promise;
}
Auto-Refresh Implementation
Client Credentials tokens expire in 24 hours. Here's a production-ready implementation with automatic refresh:
interface StoredToken {
accessToken: string;
expiresAt: Date;
}
// In-memory cache (use Redis in production for multi-instance deployments)
const tokenCache = new Map();
/**
* Get a valid access token, automatically refreshing if needed
*/
async function getAccessToken(
shopDomain: string,
clientId: string,
clientSecret: string
): Promise {
const BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry
const cached = tokenCache.get(shopDomain);
// Return cached token if still valid
if (cached && cached.expiresAt.getTime() > Date.now() + BUFFER_MS) {
return cached.accessToken;
}
// Fetch new token
console.log(`[Token] Refreshing token for ${shopDomain}`);
const response = await fetchClientCredentialsToken(
shopDomain,
clientId,
clientSecret
);
// Cache with expiry time
const expiresAt = new Date(Date.now() + response.expires_in * 1000);
tokenCache.set(shopDomain, {
accessToken: response.access_token,
expiresAt,
});
console.log(`[Token] New token expires at ${expiresAt.toISOString()}`);
return response.access_token;
}
Important: Content-Type Header
A common mistake is using application/json for Client Credentials requests. Shopify requires application/x-www-form-urlencoded:
// ❌ WRONG - Will fail
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", ... })
// ✅ CORRECT
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", ... }).toString()
Token Exchange for Embedded Apps
If you're building an embedded Shopify app that runs inside the Shopify Admin, use the Token Exchange flow with App Bridge. This provides the best user experience—no redirects or popups.
How It Works
- App Bridge generates a short-lived session token (JWT)
- Your backend exchanges this session token for an access token
- The access token is used for API calls
Frontend: Get Session Token
// In your embedded app
import { useAppBridge } from "@shopify/app-bridge-react";
function MyComponent() {
const shopify = useAppBridge();
async function makeAuthenticatedRequest() {
// Get a fresh session token
const sessionToken = await shopify.idToken();
// Send to your backend
const response = await fetch("/api/my-endpoint", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
return response.json();
}
}
Backend: Exchange Session Token
interface TokenExchangeResponse {
access_token: string;
scope: string;
expires_in: number;
}
async function exchangeSessionToken(
sessionToken: string,
shopDomain: string,
clientId: string,
clientSecret: string
): Promise {
const response = await fetch(
`https://${shopDomain}/admin/oauth/access_token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
client_id: clientId,
client_secret: clientSecret,
subject_token: sessionToken,
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
}).toString(),
}
);
if (!response.ok) {
throw new Error(`Token exchange failed: ${await response.text()}`);
}
return response.json();
}
Token Types
When exchanging session tokens, you can request different token types:
urn:shopify:params:oauth:token-type:offline-access-token- Long-lived token that doesn't expireurn:shopify:params:oauth:token-type:online-access-token- Short-lived token tied to the user's session
For most use cases, request offline tokens so your background jobs can continue working even when the merchant isn't actively using your app.
Security Best Practices
Encrypt Secrets at Rest
Never store client secrets or access tokens in plaintext. Use AES-256-GCM encryption for both:
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
/**
* Encrypts a secret using AES-256-GCM
* Returns base64-encoded string: IV + AuthTag + Ciphertext
*/
export function encryptSecret(plaintext: string): string {
const key = getEncryptionKey(); // 32-byte key from env var
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Concatenate IV + Tag + Encrypted data
const result = Buffer.concat([iv, tag, encrypted]);
return result.toString("base64");
}
/**
* Decrypts a secret encrypted with encryptSecret
*/
export function decryptSecret(ciphertext: string): string {
const key = getEncryptionKey();
const data = Buffer.from(ciphertext, "base64");
if (data.length < IV_LENGTH + TAG_LENGTH) {
throw new Error("Invalid encrypted data: too short");
}
const iv = data.subarray(0, IV_LENGTH);
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString("utf8");
}
function getEncryptionKey(): Buffer {
const keyHex = process.env.CREDENTIAL_ENCRYPTION_KEY;
if (!keyHex || keyHex.length !== 64) {
throw new Error(
"CREDENTIAL_ENCRYPTION_KEY must be a 64-character hex string. " +
"Generate one with: openssl rand -hex 32"
);
}
return Buffer.from(keyHex, "hex");
}
Additional Security Measures
Always use HTTPS - OAuth callbacks must use HTTPS (except localhost for development)
Validate state parameters - Never skip CSRF verification:
// Generate state const state = crypto.randomBytes(32).toString("hex"); await storeState(state, { shopDomain, timestamp: Date.now() }); // Verify state const stored = await getAndDeleteState(state); if (!stored || Date.now() - stored.timestamp > 600000) { throw new Error("Invalid or expired state"); }Verify shop domain format - Prevent open redirect attacks:
function isValidShopDomain(domain: string): boolean { return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i.test(domain); }Use short-lived authorization codes - Exchange codes immediately; they expire in minutes
Implement token rotation - For Client Credentials, refresh proactively before expiry
Audit token usage - Log all token operations for security review
OAuth Session State Management
For production applications, track the OAuth session state to handle edge cases like expired sessions, user cancellations, and polling from desktop apps:
// types
type OAuthStatus = "idle" | "pending" | "complete" | "error" | "cancelled";
interface OAuthSession {
sessionId: string;
status: OAuthStatus;
expiresAt: Date;
errorMessage: string | null;
}
/**
* Generate a cryptographically secure session ID
* Used as the state parameter and for desktop polling
*/
function generateSessionId(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Start an OAuth session with 10-minute expiry
*/
async function startOAuthSession(userId: string, shopDomain: string) {
const sessionId = generateSessionId();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await db.shopifyStore.update({
where: { userId_shopDomain: { userId, shopDomain } },
data: {
oauthSessionId: sessionId,
oauthStatus: "pending",
oauthExpiresAt: expiresAt,
errorMessage: null,
},
});
return { sessionId, expiresAt };
}
/**
* Complete OAuth session - encrypt token before storage
*/
async function completeOAuthSession(
sessionId: string,
accessToken: string,
scopes: string
) {
await db.shopifyStore.update({
where: { oauthSessionId: sessionId },
data: {
oauthStatus: "complete",
oauthExpiresAt: null,
accessToken: encryptSecret(accessToken), // Always encrypt!
scopes,
lastTokenRefresh: new Date(),
errorMessage: null,
},
});
}
/**
* Handle OAuth failures gracefully
*/
async function failOAuthSession(sessionId: string, errorMessage: string) {
await db.shopifyStore.update({
where: { oauthSessionId: sessionId },
data: {
oauthStatus: "error",
errorMessage,
},
});
}
This pattern enables:
- Desktop app polling - The app can check session status without needing a callback
- Session expiry - Prevents stale OAuth attempts from completing
- Error tracking - Store error messages for debugging and user feedback
- Clean state transitions - Know exactly where you are in the OAuth flow
Invalidate Tokens on Credential Change
When a user updates their OAuth credentials, always clear existing tokens:
async function saveCredentials(input: SaveCredentialsInput) {
const encryptedSecret = encryptSecret(input.clientSecret);
await db.shopifyStore.upsert({
where: {
userId_shopDomain: {
userId: input.userId,
shopDomain: input.shopDomain,
},
},
create: {
// ... create new store
},
update: {
clientId: input.clientId,
clientSecretEnc: encryptedSecret,
// IMPORTANT: Clear existing token when credentials change
accessToken: null,
tokenExpiresAt: null,
scopes: null,
oauthSessionId: null,
oauthStatus: "idle",
errorMessage: null,
},
});
}
This prevents using an old token with new credentials, which would fail anyway and create confusing error states.
Real-World Example: Desktop App Integration
Here's how we integrate OAuth with Sagify, our desktop application for syncing Shopify with Sage 50. This is production code from our C# WPF application.
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Sagify Desktop │ │ Web Backend │ │ Shopify │
│ (C# WinForms) │ │ (Node.js) │ │ │
└────────┬────────┘ └────────┬────────┘ └────────┬─────────┘
│ │ │
│ 1. Save credentials │ │
│──────────────────────>│ │
│ │ │
│ 2. Start OAuth │ │
│──────────────────────>│ │
│ │ │
│ 3. { sessionId, url } │ │
│<──────────────────────│ │
│ │ │
│ 4. Open browser │ │
│─────────────────────────────────────────────> │
│ │ │
│ │ 5. User authorizes │
│ │<──────────────────────│
│ │ │
│ │ 6. Callback + token │
│ │<──────────────────────│
│ │ │
│ 7. Poll for token │ │
│──────────────────────>│ │
│ │ │
│ 8. Return access token│ │
│<──────────────────────│ │
│ │ │
The OAuth Service (C#)
Here's the core OAuth service from Sagify that handles the complete authorization code flow:
// SagifyCommon/OAuth/ShopifyOAuthService.cs
public class ShopifyOAuthService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private const int DefaultPollIntervalMs = 2000; // Poll every 2 seconds
private const int DefaultTimeoutSeconds = 300; // 5 minute timeout
///
/// Initiates the OAuth flow. Opens browser and polls for completion.
///
public async Task AuthorizeAsync(
string shopDomain,
string storeName,
string authToken,
string product,
CancellationToken cancellationToken = default,
IProgress progress = null)
{
// Validate and normalize shop domain
if (!ValidateShopDomain(shopDomain, out var normalizedDomain))
{
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.InvalidShop,
ErrorMessage = "Invalid Shopify store domain format."
};
}
try
{
// 1. Start OAuth session
var startResponse = await StartOAuthSessionAsync(
normalizedDomain, storeName, product, authToken, cancellationToken);
if (!startResponse.Success)
{
return new OAuthResult
{
Success = false,
ErrorMessage = startResponse.Error ?? "Failed to start OAuth session"
};
}
// 2. Open browser to authorization URL
OpenBrowser(startResponse.AuthorizationUrl);
// 3. Poll for completion
return await PollForTokenAsync(
startResponse.SessionId, authToken, cancellationToken, progress);
}
catch (OperationCanceledException)
{
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.Cancelled,
ErrorMessage = "Authorization was cancelled"
};
}
catch (HttpRequestException ex)
{
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.NetworkError,
ErrorMessage = $"Network error: {ex.Message}"
};
}
}
///
/// Validates and normalizes a Shopify shop domain
///
public bool ValidateShopDomain(string shopDomain, out string normalizedDomain)
{
normalizedDomain = null;
if (string.IsNullOrWhiteSpace(shopDomain)) return false;
// Normalize: remove protocol, trailing slashes
var normalized = shopDomain.Trim()
.ToLowerInvariant()
.Replace("https://", "")
.Replace("http://", "")
.TrimEnd('/');
// Add .myshopify.com if missing
if (!normalized.EndsWith(".myshopify.com"))
{
if (!normalized.Contains("."))
normalized += ".myshopify.com";
else
return false;
}
// Validate format: alphanumeric and hyphens only
var shopName = normalized.Replace(".myshopify.com", "");
if (!Regex.IsMatch(shopName, @"^[a-z0-9][a-z0-9\-]*[a-z0-9]$|^[a-z0-9]$"))
return false;
normalizedDomain = normalized;
return true;
}
private void OpenBrowser(string url)
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
}
Polling for Token Completion
Desktop apps can't receive OAuth callbacks directly, so we poll the backend:
private async Task PollForTokenAsync(
string sessionId,
string authToken,
CancellationToken cancellationToken,
IProgress progress)
{
var startTime = DateTime.UtcNow;
var timeoutTime = startTime.AddSeconds(_timeoutSeconds);
while (DateTime.UtcNow < timeoutTime)
{
cancellationToken.ThrowIfCancellationRequested();
var elapsed = (int)(DateTime.UtcNow - startTime).TotalSeconds;
progress?.Report(new OAuthProgress
{
State = OAuthProgressState.WaitingForAuthorization,
Message = $"Waiting for authorization... ({elapsed}s)",
ElapsedSeconds = elapsed
});
var request = new HttpRequestMessage(
HttpMethod.Get, $"{_baseUrl}/shopify/auth/token/{sessionId}");
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", authToken);
var response = await _httpClient.SendAsync(request, cancellationToken);
var content = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.SessionExpired,
ErrorMessage = "OAuth session expired. Please try again."
};
}
var tokenResponse = JsonConvert.DeserializeObject(content);
switch (tokenResponse.Status?.ToLowerInvariant())
{
case "complete":
return new OAuthResult
{
Success = true,
AccessToken = tokenResponse.AccessToken,
ShopDomain = tokenResponse.ShopDomain,
Scopes = tokenResponse.Scopes
};
case "error":
return new OAuthResult
{
Success = false,
ErrorMessage = tokenResponse.Error ?? "Authorization failed"
};
case "cancelled":
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.Cancelled,
ErrorMessage = "Authorization was declined by user."
};
case "pending":
default:
await Task.Delay(_pollIntervalMs, cancellationToken);
break;
}
}
return new OAuthResult
{
Success = false,
ErrorCode = OAuthErrorCode.Timeout,
ErrorMessage = $"Authorization timed out after {_timeoutSeconds} seconds."
};
}
Token Encryption with Windows DPAPI
On Windows desktop apps, we use the Data Protection API (DPAPI) to encrypt tokens at rest:
// SagifyCommon/Security/DPAPITokenEncryption.cs
using System.Security.Cryptography;
public class DPAPITokenEncryption : ITokenEncryption
{
///
/// Encrypts using Windows DPAPI - tied to current user account
///
public string Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText)) return string.Empty;
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var encryptedBytes = ProtectedData.Protect(
plainBytes,
null,
DataProtectionScope.CurrentUser);
// Clear plain text from memory
Array.Clear(plainBytes, 0, plainBytes.Length);
return Convert.ToBase64String(encryptedBytes);
}
public string Decrypt(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText)) return string.Empty;
var encryptedBytes = Convert.FromBase64String(encryptedText);
var decryptedBytes = ProtectedData.Unprotect(
encryptedBytes,
null,
DataProtectionScope.CurrentUser);
var result = Encoding.UTF8.GetString(decryptedBytes);
// Clear decrypted bytes from memory
Array.Clear(decryptedBytes, 0, decryptedBytes.Length);
return result;
}
///
/// Detects if a string is encrypted (vs raw Shopify token)
///
public bool IsEncrypted(string text)
{
if (string.IsNullOrEmpty(text)) return false;
// Shopify tokens start with "shpat_" or "shpca_" (unencrypted)
if (text.StartsWith("shp", StringComparison.OrdinalIgnoreCase))
return false;
// Check if valid Base64 with reasonable length
if (text.Length % 4 != 0) return false;
try
{
var decoded = Convert.FromBase64String(text);
return decoded.Length >= 50; // DPAPI output is substantial
}
catch
{
return false;
}
}
}
OAuth Data Models (C#)
// SagifyCommon/OAuth/OAuthModels.cs
public class OAuthStartResponse
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("sessionId")]
public string SessionId { get; set; }
[JsonProperty("authorizationUrl")]
public string AuthorizationUrl { get; set; }
[JsonProperty("expiresInSeconds")]
public int ExpiresInSeconds { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
}
public class OAuthTokenResponse
{
[JsonProperty("status")]
public string Status { get; set; } // pending, complete, error, expired, cancelled
[JsonProperty("accessToken")]
public string AccessToken { get; set; }
[JsonProperty("shopDomain")]
public string ShopDomain { get; set; }
[JsonProperty("scopes")]
public string[] Scopes { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
}
public enum OAuthErrorCode
{
None = 0,
Timeout,
Cancelled,
AccessDenied,
InvalidShop,
NetworkError,
ServerError,
SessionExpired,
AuthenticationRequired
}
public class OAuthResult
{
public bool Success { get; set; }
public string AccessToken { get; set; }
public string ShopDomain { get; set; }
public string[] Scopes { get; set; }
public string ErrorMessage { get; set; }
public OAuthErrorCode ErrorCode { get; set; }
}
Supporting Both OAuth Flows
The Sagify UI supports both Authorization Code (via ezApps) and Client Credentials (user's own app):
// Checkbox determines which flow to use
if (chkOwnApp.Checked)
{
// Client Credentials flow - direct API call, no browser
await ExecuteClientCredentialsFlowAsync(clientId, clientSecret);
}
else
{
// Authorization Code flow - opens browser for user consent
await ExecuteAuthorizationCodeFlowAsync(clientId, clientSecret);
}
Why This Architecture?
- Desktop apps can't receive callbacks - OAuth requires HTTPS callbacks, which desktop apps can't easily host
- Credentials stay server-side - Client secrets are stored encrypted on the backend, not in desktop app config files
- Works offline - Once authenticated, the backend can refresh tokens without user interaction
- Centralized security - All encryption and token management happens server-side with AES-256-GCM
- Multi-platform - Same backend serves both desktop applications
Troubleshooting Common OAuth Errors
Error Reference
| Error | Cause | Solution |
|---|---|---|
invalid_client |
Wrong client_id or client_secret | Verify credentials match your app's API credentials in Partners Dashboard |
invalid_grant |
Authorization code expired or already used | Codes are single-use and expire quickly. Exchange immediately after receiving. |
invalid_request |
Malformed request, missing parameters | Check Content-Type header (form-urlencoded for client_credentials, JSON for auth code exchange) |
access_denied |
User declined authorization | Handle gracefully - redirect to an error page explaining they need to authorize |
invalid_scope |
Requested scopes not configured for app | Add required scopes in Partners Dashboard → API access |
| HMAC verification failed | Tampered request or wrong secret | Ensure you're using the correct client_secret for HMAC calculation |
Common Mistakes
1. Wrong Content-Type for Client Credentials
// ❌ Wrong
fetch(url, {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", ... })
})
// ✅ Correct
fetch(url, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", ... }).toString()
})
2. Not URL-encoding shop domain
// ❌ May fail with special characters
const url = `https://${shop}/admin/oauth/access_token`;
// ✅ Safe
const normalizedShop = shop.toLowerCase().replace(/^https?:\/\//, "").split("/")[0];
3. Skipping state verification
// ❌ Vulnerable to CSRF attacks
if (code && shop) {
await exchangeToken(code);
}
// ✅ Always verify state
const storedSession = await getSession(state);
if (!storedSession || storedSession.shop !== shop) {
throw new Error("Invalid state");
}
4. Exposing client_secret client-side
// ❌ NEVER do this in frontend code
const secret = "shpss_xxxxxxxxxxxxx";
fetch(`/oauth?secret=${secret}`);
// ✅ Keep secrets on the server
// Frontend only initiates the flow, backend handles credentials
Migration Checklist
If you're migrating from the old Custom App tokens to OAuth, here's your checklist:
Before You Start
- Document all existing integrations using static tokens
- Identify which OAuth flow each integration needs
- Create backup of current token/credential storage
- Set up secure credential storage (encrypted database fields)
Implementation
- Create app in Shopify Partners Dashboard
- Configure OAuth redirect URIs
- Set required API scopes
- Implement chosen OAuth flow(s)
- Add HMAC verification for callbacks
- Implement state parameter for CSRF protection
- Add token refresh logic (for Client Credentials)
- Encrypt stored credentials and tokens
Testing
- Test OAuth flow with development store
- Verify HMAC validation works correctly
- Test token expiry and refresh (Client Credentials)
- Test error handling for all failure cases
- Verify scopes are correct for all API calls
Deployment
- Deploy new OAuth endpoints
- Update environment variables (client_id, client_secret, encryption keys)
- Re-authenticate all existing stores
- Verify all integrations work with new tokens
- Deprecate and remove old static token code
- Update documentation
FAQ
Do OAuth tokens expire?
Authorization Code tokens: No, they don't expire. Once you have a token from the Authorization Code flow, it works until the merchant uninstalls your app or revokes access.
Client Credentials tokens: Yes, they expire in 24 hours (expires_in: 86399 seconds). Implement automatic refresh before expiry.
Token Exchange tokens: It depends on the requested token type. Offline tokens don't expire; online tokens are tied to the user's session.
Can I still use static tokens?
No. Shopify has removed the ability to generate static tokens from the admin interface. All new integrations must use OAuth. Existing static tokens may continue to work for now, but you should migrate to OAuth.
Which flow should I use for a desktop app?
For a desktop app accessing your own stores:
- Client Credentials if you want simple, automatic authentication
- Authorization Code if you need user-visible consent or plan to support stores you don't own
For accessing other merchants' stores, you must use Authorization Code since the merchant needs to authorize access.
How do I handle multiple stores?
Store credentials and tokens per-shop. Use the shop domain as the key:
interface StoreCredentials {
shopDomain: string;
clientId: string;
clientSecret: string; // Encrypted
accessToken: string; // Encrypted
tokenExpiresAt: Date | null;
}
// One record per shop per user
const store = await db.shopifyStore.findUnique({
where: {
userId_shopDomain: {
userId: currentUser.id,
shopDomain: "mystore.myshopify.com"
}
}
});
What's the difference between client_id and API Key?
They're the same thing. Shopify's documentation sometimes uses "API Key" and sometimes uses "client_id". Similarly, "API Secret Key" and "client_secret" are the same.
How do I test OAuth locally?
- Use a tunneling service like ngrok ↗ to expose your local server
- Register the ngrok URL as an allowed redirect URI in your app settings
- Use a development store (create one in Partners Dashboard)
ngrok http 3000
# Use the https URL as your redirect URI
Can I use OAuth for Shopify CLI apps?
Yes! The Shopify CLI and app templates handle OAuth automatically. When you run shopify app dev, it sets up ngrok tunneling and OAuth flows for you. This guide is more relevant for custom implementations or non-standard architectures like desktop apps.
Conclusion
Getting Shopify access tokens has changed significantly, but the new OAuth approach is ultimately more secure and flexible. Here's what to remember:
- Choose the right flow: Authorization Code for public apps, Client Credentials for your own stores, Token Exchange for embedded apps
- Security is non-negotiable: Always verify HMAC signatures, use state parameters, and encrypt stored credentials
- Handle token lifecycle: Client Credentials tokens expire in 24 hours—implement auto-refresh
- Test thoroughly: OAuth has many failure modes. Test them all.
The code examples in this guide are battle-tested in our production apps. They handle real-world edge cases and security requirements.
The code examples in this guide are from our production applications. If you have questions, feel free to reach out.
Last updated: February 2026