Frameworks like Shopify App Remix and the official Node SDK hide a lot of complexity. That's great for shipping fast, but it means many developers don't actually understand what's happening under the hood. If you're building a Shopify app in Python, Go, Ruby, or any language without a Shopify-maintained SDK, or if you just want to deeply understand the security model you're relying on—you need to implement OAuth yourself. This tutorial walks through the complete Shopify OAuth 2.0 authorization code flow, step by step, using plain Node.js HTTP primitives. The concepts translate directly to any language.
Before you start
- A public app registered in the Partner Dashboard. From the Partners Dashboard, go to Apps → Create app → Custom app or Public app. You'll receive a Client ID (also called API key) and a Client Secret.
- An HTTPS redirect URL. Shopify requires HTTPS. For local development, use a tool like ngrok or Cloudflare Tunnel to expose your local port over HTTPS. Your redirect URL will be something like
https://abc123.ngrok.io/auth/callback. - Basic HTTP server knowledge. You should understand what a query parameter is and how to set a cookie. Nothing more advanced than that.
Understand the OAuth flow (four steps in plain language)
Before writing any code, understand what you're building:
- Merchant installs your app. Shopify sends them to your install URL (e.g.,
https://your-app.com/auth?shop=their-store.myshopify.com) with HMAC verification parameters. - Your server redirects to Shopify's permission screen. You redirect the merchant to
https://their-store.myshopify.com/admin/oauth/authorizewith your Client ID, requested scopes, redirect URI, and a random state value. - Merchant approves. Shopify redirects them back to your redirect URI with a temporary
codequery parameter. - Your server exchanges the code for a permanent access token. You POST the code (plus your Client ID and Secret) to Shopify's token endpoint and receive an access token you can use indefinitely (until the merchant uninstalls the app).
Expected outcome: You can explain the four steps from memory before writing any code.
Set up your app in Partner Dashboard
In Partner Dashboard → Your App → App setup, find the "App URL" and "Allowed redirection URL(s)" fields. Set App URL to your ngrok root (e.g., https://abc123.ngrok.io) and Allowed redirection URLs to https://abc123.ngrok.io/auth/callback.
Copy your Client ID and Client Secret to a .env file. Never commit these to Git.
SHOPIFY_API_KEY=your_client_id_here
SHOPIFY_API_SECRET=your_client_secret_here
SHOPIFY_SCOPES=read_products,write_products
HOST=https://abc123.ngrok.ioExpected outcome: Your app configuration in Partner Dashboard matches your local server's URL.
Build the install endpoint (/auth)
When Shopify sends a merchant to install your app, they land on your /auth endpoint with a shop query parameter. Your job is to generate a random state value (a nonce, used to prevent CSRF attacks), store it in a cookie, then redirect to Shopify's authorization URL.
import crypto from 'crypto';
import { URL } from 'url';
// GET /auth?shop=example.myshopify.com
app.get('/auth', (req, res) => {
const shop = req.query.shop;
if (!shop || !shop.match(/^[a-zA-Z0-9-]+\.myshopify\.com$/)) {
return res.status(400).send('Invalid shop parameter');
}
const state = crypto.randomBytes(16).toString('hex');
// Store state in a short-lived cookie
res.cookie('shopify_state', state, { httpOnly: true, secure: true, maxAge: 60000 });
const authUrl = new URL(`https://${shop}/admin/oauth/authorize`);
authUrl.searchParams.set('client_id', process.env.SHOPIFY_API_KEY);
authUrl.searchParams.set('scope', process.env.SHOPIFY_SCOPES);
authUrl.searchParams.set('redirect_uri', `${process.env.HOST}/auth/callback`);
authUrl.searchParams.set('state', state);
res.redirect(authUrl.toString());
});Expected outcome: Visiting /auth?shop=your-store.myshopify.com redirects to Shopify's permission screen.
Handle the callback: verify HMAC, verify state, exchange code
After the merchant approves, Shopify sends them to your redirect URI with code, shop, state, and hmac query params. You must verify both the HMAC and the state before trusting anything else.
import fetch from 'node-fetch';
// GET /auth/callback?code=...&hmac=...&shop=...&state=...
app.get('/auth/callback', async (req, res) => {
const { code, hmac, shop, state } = req.query;
// 1. Verify state matches what we set in the cookie
if (state !== req.cookies.shopify_state) {
return res.status(403).send('State mismatch. Possible CSRF attack.');
}
// 2. Verify HMAC
const params = { ...req.query };
delete params.hmac;
const message = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&');
const expectedHmac = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(message)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(expectedHmac))) {
return res.status(403).send('HMAC verification failed.');
}
// 3. Exchange code for access token
const tokenResponse = await fetch(`https://${shop}/admin/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.SHOPIFY_API_KEY,
client_secret: process.env.SHOPIFY_API_SECRET,
code,
}),
});
const { access_token, scope } = await tokenResponse.json();
// 4. Persist the token (see next step)
await saveToken(shop, access_token);
res.redirect(`https://${shop}/admin/apps/${process.env.SHOPIFY_API_KEY}`);
});Expected outcome: After a merchant approves installation, your callback endpoint verifies both security checks and receives a permanent access token.
Persist the access token securely
The access token is permanent—it works until the merchant uninstalls your app or you re-request scopes. Store it in a database, not in memory. An in-memory store will lose all tokens on every server restart. A minimal persistent store for development: a sessions.json file. For production: a proper database (PostgreSQL, SQLite, Redis) keyed by shop domain.
Never log the access token. Never send it to the client. Never store it in a cookie. It is a server-side secret that belongs in encrypted-at-rest server storage.
// Minimal file-based session store (development only)
import fs from 'fs';
const DB_FILE = './sessions.json';
export function saveToken(shop, accessToken) {
const db = fs.existsSync(DB_FILE) ? JSON.parse(fs.readFileSync(DB_FILE)) : {};
db[shop] = { accessToken, installedAt: new Date().toISOString() };
fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
}
export function getToken(shop) {
if (!fs.existsSync(DB_FILE)) return null;
const db = JSON.parse(fs.readFileSync(DB_FILE));
return db[shop]?.accessToken || null;
}Expected outcome: Access tokens survive server restarts and are retrievable by shop domain.
Verify install requests with HMAC
Before redirecting to Shopify's OAuth screen in Step 3, you should also verify that the install request actually came from Shopify (not a malicious third party pretending to be a store). Shopify includes an hmac param on the initial install request. The verification algorithm is the same: sort all params except hmac, join as key=value pairs, HMAC-SHA256 with your client secret, compare.
// Reusable HMAC verification
export function verifyShopifyHmac(query, secret) {
const { hmac, ...rest } = query;
if (!hmac) return false;
const message = Object.keys(rest)
.sort()
.map(k => `${k}=${rest[k]}`)
.join('&');
const expected = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(hmac, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false;
}
}Expected outcome: All incoming requests from Shopify are cryptographically verified before any action is taken.
Use the access token to call the Admin API
With the access token retrieved from your session store, you can make authenticated calls to the GraphQL Admin API. Pass the token in the X-Shopify-Access-Token HTTP header.
export async function shopifyGraphQL(shop, accessToken, query, variables = {}) {
const response = await fetch(
`https://${shop}/admin/api/2024-10/graphql.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': accessToken,
},
body: JSON.stringify({ query, variables }),
}
);
if (!response.ok) {
throw new Error(`Shopify API error: ${response.status}`);
}
const json = await response.json();
if (json.errors) throw new Error(json.errors[0].message);
return json.data;
}
// Example: fetch first 10 products
const data = await shopifyGraphQL(
'example.myshopify.com',
accessToken,
`query { products(first: 10) { nodes { id title } } }`
);Expected outcome: You can retrieve data from any store where the app is installed using their stored access token.
Handle scope changes (re-authentication)
If you add new required scopes after the app is already installed, the existing access token won't include the new permissions. You need to detect this and send the merchant through the OAuth flow again. Compare the scope returned during token exchange with the scopes you currently require. Store the granted scope alongside the access token. On each authenticated request, if the granted scopes are a subset of what you need, redirect to /auth?shop=... to start the flow again. The merchant will see a new permission screen only for the added scopes.
Expected outcome: Your app gracefully upgrades permissions when you add new API scopes without breaking existing installations.
Online vs offline access modes
Shopify supports two token types. Offline tokens (the default) are permanent and tied to the store, not the individual staff member. Use offline tokens for background jobs, webhooks, and any server-to-server API calls. They are what we built above.
Online tokens are tied to a specific staff member's session and expire when they log out or the session expires. To request an online token, add grant_options[]=per-user to the authorization URL. Use online tokens when you need to make API calls on behalf of a specific user (e.g., to respect their individual permissions) or when your app stores user-specific data.
What's next
With bare OAuth working, the logical next step is App Bridge authentication—the newer session-token-based approach that works inside the embedded admin without full-page redirects. Shopify's documentation at shopify.dev/docs/apps/auth covers session tokens in detail. You'll also want to look into webhook verification (similarly HMAC-based) for receiving install/uninstall events, and GDPR-required mandatory webhooks (customers/data_request, customers/redact, shop/redact).
Registering and handling webhooks
Once your app is installed, you'll want to receive real-time notifications when things change in the merchant's store. Webhooks are HTTP POST requests that Shopify sends to a URL you register whenever a specific event occurs—an order is created, a product is updated, a customer is deleted, etc. Unlike polling (repeatedly asking the API "did anything change?"), webhooks are event-driven and far more efficient.
To register a webhook, call the webhookSubscriptionCreate mutation with the access token you received during OAuth. Every Shopify app is also required to register three mandatory GDPR webhooks—customers/data_request, customers/redact, and shop/redact—before your app can be published in the Shopify App Store. These are required by Shopify's partner agreement regardless of whether your app actually stores personal data.
When your server receives a webhook, you must verify it before processing it. Shopify signs each webhook payload with an HMAC-SHA256 signature using your client secret. The signature is in the X-Shopify-Hmac-Sha256 HTTP header (base64-encoded). To verify: compute HMAC-SHA256 of the raw request body bytes using your client secret, base64-encode the result, and compare it to the header value using a timing-safe comparison. If they don't match, reject the request—it didn't come from Shopify.
import crypto from 'crypto';
export function verifyWebhookHmac(rawBody, hmacHeader, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('base64');
try {
return crypto.timingSafeEqual(
Buffer.from(hmacHeader, 'base64'),
Buffer.from(computed, 'base64')
);
} catch {
return false;
}
}
// Express handler example
app.post('/webhooks/orders/created', express.raw({ type: 'application/json' }), (req, res) => {
const hmac = req.headers['x-shopify-hmac-sha256'];
if (!verifyWebhookHmac(req.body, hmac, process.env.SHOPIFY_API_SECRET)) {
return res.status(401).send('Unauthorized');
}
const order = JSON.parse(req.body);
// Process order...
res.status(200).send('OK');
});Common security mistakes to avoid
Building OAuth from scratch exposes you to several security pitfalls that frameworks handle automatically. Be aware of these before shipping:
- Never use string equality for HMAC comparison. Normal string comparison short-circuits at the first mismatching character, which leaks timing information an attacker can exploit. Always use crypto.timingSafeEqual() or its equivalent in your language.
- Validate the shop parameter strictly. Before using a shop domain from query params, verify it matches the pattern
/^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/. Failing to do so opens your app to redirecting users to arbitrary domains. - Use short-lived state cookies. Set a maxAge of 60 seconds on the state cookie—just long enough for the merchant to approve permissions. A long-lived state cookie provides less CSRF protection.
- Always use HTTPS. Access tokens transmitted over HTTP can be intercepted. Shopify enforces HTTPS on your redirect URL, but make sure all your internal routes—including webhook endpoints—are also served over HTTPS in production.
Frequently asked questions
- What happens to the access token when a merchant uninstalls my app?
When a merchant uninstalls your app, Shopify immediately revokes the access token—it stops working for API calls. Shopify fires an app/uninstalled webhook to notify you. Your handler should delete the stored token and any associated data. This is also one of the three GDPR-mandatory webhooks you must implement for App Store approval.
- Can I use the same access token across multiple stores?
No. Each store has its own unique access token. Tokens are scoped to a specific store and cannot be transferred or reused. Your session storage must key tokens by shop domain, so when your server receives a request for a given store, it retrieves only that store's token.
- Is it safe to put my Shopify API key in environment variables on a public server?
Environment variables are the correct place for server-side secrets—they're never sent to clients and aren't checked into source control. What's critical is that your hosting platform keeps environment variables encrypted at rest and not visible in logs. Platforms like Vercel, Fly.io, and Railway all handle this correctly. Never put your Client ID or Client Secret in your frontend JavaScript bundle or any publicly accessible file.
