Skip to content

Instantly share code, notes, and snippets.

@shafiimam
Created April 12, 2026 12:36
Show Gist options
  • Select an option

  • Save shafiimam/777b8b665ea01005782ecb48cad9daf1 to your computer and use it in GitHub Desktop.

Select an option

Save shafiimam/777b8b665ea01005782ecb48cad9daf1 to your computer and use it in GitHub Desktop.
Shopify Offline OAuth Token Handshake Script (2026)
/**
* shopify-oauth-offline.js
*
* Completes Shopify OAuth to obtain a non-expiring offline access token
* (authorization code grant). Offline mode = do NOT add grant_options[]=per-user
* to the authorize URL — see:
* https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant
*
* Shopify often rejects http://127.0.0.1 redirect URLs in the Partners dashboard.
* Use a public HTTPS URL via Cloudflare Tunnel (quick tunnel is fine for one-off token capture).
*
* Order of operations:
* 1) cloudflared tunnel --url http://127.0.0.1:3456 (other terminal; keep running)
* 2) Copy the https://….trycloudflare.com host (new each run for quick tunnels)
* 3) Partners → Allowed redirection URL(s): https://THAT-HOST/auth/callback
* 4) .env → OAUTH_REDIRECT_URI=https://THAT-HOST/auth/callback
* 5) node shopify-oauth-offline.js → open the printed OAuth URL
*
* Env (add to .env — never commit real secrets):
* OAUTH_SHOP=your-store.myshopify.com
* OAUTH_CLIENT_ID=...
* OAUTH_CLIENT_SECRET=shpss_...
* OAUTH_REDIRECT_URI=https://YOUR-SUBDOMAIN.trycloudflare.com/auth/callback (required for tunnel flow)
* OAUTH_SCOPES=read_products,write_products (optional)
* OAUTH_PORT=3456 (optional; must match cloudflared --url port)
* OAUTH_BIND_HOST=127.0.0.1 (optional; 0.0.0.0 if tunnel points at LAN IP)
*
* Run:
* node shopify-oauth-offline.js
*/
import http from 'http';
import crypto from 'crypto';
import { URL } from 'url';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
dotenv.config();
const SHOP = process.env.OAUTH_SHOP?.trim();
const CLIENT_ID = process.env.OAUTH_CLIENT_ID?.trim();
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET?.trim();
const SCOPES = (process.env.OAUTH_SCOPES || 'read_products,write_products').trim();
const PORT = Number(process.env.OAUTH_PORT || 3456);
const BIND_HOST = (process.env.OAUTH_BIND_HOST || '127.0.0.1').trim();
const REDIRECT_URI_RAW = process.env.OAUTH_REDIRECT_URI?.trim();
const REDIRECT_URI = REDIRECT_URI_RAW || `http://127.0.0.1:${PORT}/auth/callback`;
let callbackPath = '/auth/callback';
try {
const ru = new URL(REDIRECT_URI);
callbackPath = ru.pathname.endsWith('/') && ru.pathname !== '/'
? ru.pathname.slice(0, -1)
: ru.pathname;
if (!callbackPath || callbackPath === '') callbackPath = '/auth/callback';
} catch {
console.error('Invalid OAUTH_REDIRECT_URI — must be a full URL, e.g. https://x.trycloudflare.com/auth/callback');
process.exit(1);
}
/**
* Shopify signs the OAuth callback using the client secret and a message built from
* all query params except hmac/signature, sorted by key, as key=value joined with &.
* Values must be decoded per application/x-www-form-urlencoded (+ is space).
* URLSearchParams alone is a common source of HMAC mismatches; parse the raw query.
* @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant
*/
function decodeFormUrlComponent(encoded) {
if (encoded == null || encoded === '') return '';
return decodeURIComponent(String(encoded).replace(/\+/g, ' '));
}
function verifyShopifyHmacFromRawQuery(queryString, secret) {
if (!secret || queryString == null) return false;
const qs = queryString.startsWith('?') ? queryString.slice(1) : queryString;
let receivedHmac = '';
/** @type {Record<string, string>} */
const data = Object.create(null);
for (const segment of qs.split('&')) {
if (segment === '') continue;
const eq = segment.indexOf('=');
const rawKey = eq === -1 ? segment : segment.slice(0, eq);
const rawVal = eq === -1 ? '' : segment.slice(eq + 1);
const key = decodeFormUrlComponent(rawKey);
const value = decodeFormUrlComponent(rawVal);
if (key === 'hmac') {
receivedHmac = value;
continue;
}
if (key === 'signature') continue;
data[key] = value;
}
if (!receivedHmac) return false;
const sortedKeys = Object.keys(data).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const message = sortedKeys.map(k => `${k}=${data[k]}`).join('&');
const digest = crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex');
const a = Buffer.from(digest.toLowerCase(), 'utf8');
const b = Buffer.from(String(receivedHmac).toLowerCase().trim(), 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function normalizeShop(host) {
const h = host.replace(/^https?:\/\//, '').split('/')[0].toLowerCase();
if (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(h)) {
throw new Error(`Invalid shop hostname: ${host}`);
}
return h;
}
async function exchangeCode(shop, code) {
const url = `https://${shop}/admin/oauth/access_token`;
const body = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code
});
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
},
body
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Token exchange failed (${res.status}): ${text}`);
}
if (!res.ok) {
throw new Error(`Token exchange failed (${res.status}): ${text}`);
}
return data;
}
function isLocalRedirect(uri) {
try {
const u = new URL(uri);
return u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost');
} catch {
return false;
}
}
function main() {
if (!SHOP || !CLIENT_ID || !CLIENT_SECRET) {
console.error('Set OAUTH_SHOP, OAUTH_CLIENT_ID, and OAUTH_CLIENT_SECRET in .env');
process.exit(1);
}
if (!REDIRECT_URI_RAW && isLocalRedirect(REDIRECT_URI)) {
console.error('');
console.error('Shopify Partners usually does not allow localhost redirect URLs.');
console.error('Set OAUTH_REDIRECT_URI to your Cloudflare tunnel HTTPS callback URL, e.g.:');
console.error(' OAUTH_REDIRECT_URI=https://random-words.trycloudflare.com/auth/callback');
console.error('');
console.error('Then run in another terminal:');
console.error(` cloudflared tunnel --url http://127.0.0.1:${PORT}`);
console.error('');
process.exit(1);
}
if (!REDIRECT_URI.startsWith('https://')) {
console.warn('Warning: redirect URI is not https:// — Shopify may reject it.');
}
const shop = normalizeShop(SHOP);
const state = crypto.randomBytes(16).toString('hex');
let settled = false;
const authUrl = new URL(`https://${shop}/admin/oauth/authorize`);
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('scope', SCOPES);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('state', state);
const server = http.createServer(async (req, res) => {
const pathOnly = req.url?.split('?')[0] ?? '';
if (pathOnly !== callbackPath) {
res.writeHead(404);
res.end('Not found');
return;
}
const q = req.url.indexOf('?');
const queryString = q === -1 ? '' : req.url.slice(q + 1);
const params = new URLSearchParams(queryString);
if (params.get('state') !== state) {
res.writeHead(400);
res.end('Invalid state — restart the script and try again.');
return;
}
if (!verifyShopifyHmacFromRawQuery(queryString, CLIENT_SECRET)) {
res.writeHead(400);
res.end(
'HMAC verification failed. Confirm OAUTH_CLIENT_SECRET is the app Client secret ' +
'(shpss_…) from the same Partners app as OAUTH_CLIENT_ID, with no extra spaces in .env.'
);
return;
}
const code = params.get('code');
const callbackShop = params.get('shop');
if (!code || !callbackShop) {
res.writeHead(400);
res.end('Missing code or shop.');
return;
}
try {
const tokenPayload = await exchangeCode(callbackShop, code);
const accessToken = tokenPayload.access_token;
const scope = tokenPayload.scope;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html><html><body><p>Success. You can close this tab.</p></body></html>`);
if (!settled) {
settled = true;
console.log('');
console.log('--- Offline access token (store in a secret manager / .env) ---');
console.log('access_token:', accessToken);
console.log('scope:', scope);
if (tokenPayload.expires_in != null) {
console.log('note: response included expires_in — this may be an online or expiring token; check Shopify app settings.');
}
console.log('');
console.log('Use as Admin API token: X-Shopify-Access-Token header with GraphQL/REST.');
server.close();
process.exit(0);
}
} catch (err) {
res.writeHead(500);
res.end(String(err.message || err));
console.error(err);
server.close();
process.exit(1);
}
});
server.listen(PORT, BIND_HOST, () => {
const tunnelTarget = BIND_HOST === '0.0.0.0' ? '127.0.0.1' : BIND_HOST;
console.log('');
console.log(`Listening on http://${BIND_HOST}:${PORT} (callback path: ${callbackPath})`);
console.log('');
console.log('If you have not already: in another terminal run Cloudflare quick tunnel and set OAUTH_REDIRECT_URI to https://…/auth/callback:');
console.log(` cloudflared tunnel --url http://${tunnelTarget}:${PORT}`);
console.log('');
console.log('1. Partners / Dev Dashboard → Allowed redirection URL(s) must include (exact match):');
console.log(' ', REDIRECT_URI);
console.log('');
console.log('2. Open this URL in your browser (log in as store staff if prompted):');
console.log('');
console.log(authUrl.toString());
console.log('');
console.log('3. After you approve, the token prints here and the server exits.');
console.log('');
});
}
main();
@shafiimam

Copy link
Copy Markdown
Author

A lightweight Node.js utility to perform a one-time OAuth handshake for Shopify apps. Designed for internal tools, ETL processes, and private integrations where a long-lived 'offline' shpat_ access token is required. Supports HMAC verification, state validation, and works perfectly with Cloudflare tunnels for local development.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment