Shopify アプリ認証 は何をしますか?
Shopifyは、アプリのタイプとユースケースに応じて複数の認証フローを提供しています。最新の標準は、サーバーサイドレンダリングアプリ向けのトークン交換(2024年以降)、ヘッドレスアプリ向けの管理インストール、そしてレガシー/カスタム実装向けのOAuth 2.0 認可コードグラントです。すべてのフローで、Shopify GraphQL Admin APIにアクセスするためのアクセストークンが発行されます。
認証フローの概要
1. トークン交換(2024年以降推奨)
ユースケース: サーバーサイドレンダリングアプリ(Remix、SSRを使用するNext.js)、Shopify CLIアプリ フロー: マーチャントがアプリをインストール → Shopifyが一時的な交換トークンを生成 → アプリがアクセストークンと交換 セキュリティ: クライアントシークレットを公開せず、リクエストごとにPKCE方式のローテーションを使用 トークンの有効期間: アクセストークンは短命、リフレッシュトークンは自動的にローテーション
トークン交換の図:
1. マーチャントがShopify管理画面で「インストール」をクリック
2. Shopifyがリダイレクト: https://your-app.com/auth/callback?code=EXCHANGE_TOKEN
3. アプリがHMACを検証し、コードをアクセストークンと交換(サーバーサイドのみ、非公開)
4. Shopify Admin APIがスコープを付与、トークンはセッション/データベースに保存
5. トークンは期限切れの場合、次のリクエストで自動的にリフレッシュ
Remix実装(トークン交換):
// shopify.app.ts(アプリ設定)
import { shopifyApp } from '@shopify/shopify-app-remix/server';
import { restResources } from '@shopify/shopify-api/rest/admin/2026-07';
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY || '',
apiSecret: process.env.SHOPIFY_API_SECRET || '',
scopes: process.env.SCOPES?.split(',') || [
'write_products',
'read_products',
'write_orders',
'read_orders',
'write_customers',
'read_customers',
'write_discounts',
'read_discounts',
'write_fulfillments',
'read_fulfillments',
'write_inventory',
'read_inventory',
],
appUrl: process.env.SHOPIFY_APP_URL || 'http://localhost:3000',
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
webhooks: {
path: '/webhooks',
},
isEmbeddedApp: true, // Polaris管理画面
sessionStorage: new PrismaSessionStorage(prisma),
restResources, // REST APIヘルパーを含む
});
export default shopify;
環境変数 (.env):
SHOPIFY_API_KEY=your-public-api-key-from-partner-dashboard
SHOPIFY_API_SECRET=your-private-api-secret
SHOPIFY_APP_URL=https://your-domain.ngrok.io # または本番URL
SCOPES=write_products,read_products,write_orders,read_orders
認証ルート (routes/auth.$.tsx - キャッチオールルート):
import { redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { authenticate } = await import('~/shopify.server');
return authenticate.admin(request); // ここでトークン交換が行われる
};
コールバック処理 (routes/auth.callback.tsx):
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
// セッションには以下が含まれます:
// - session.accessToken(APIコールに有効)
// - session.shop(マーチャントのショップドメイン)
// - session.scope(付与されたスコープ)
// - session.state(オプションのカスタムデータ)
return redirect('/app'); // 認証後にダッシュボードへリダイレクト
};
APIコールでのトークン使用 (routes/app.products.tsx):
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { GraphQLClient } from 'graphql-request';
export const loader = async ({ request }) => {
const { session, admin } = await authenticate.admin(request);
// 方法1: Shopifyのadminヘルパーを使用(推奨)
const response = await admin.graphql(`
query GetProducts {
products(first: 10) {
edges {
node {
id
title
handle
}
}
}
}
`);
// 方法2: 手動でGraphQL呼び出し
const client = new GraphQLClient(
`https://${session.shop}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': session.accessToken,
'Content-Type': 'application/json',
},
}
);
const data = await client.request(/* ... */);
return json({ products: response.data?.products?.edges || [] });
};
トークンのリフレッシュ(自動): トークン交換のトークンは、Shopifyのセッションミドルウェアによって自動的にリフレッシュされます。手動でのリフレッシュは不要です:
// トークンは各リクエストで透過的にリフレッシュされます
const response = await admin.graphql(query); // 内部でリフレッシュを処理
2. 管理インストール(ヘッドレス/カスタムアプリ)
ユースケース: ヘッドレスストアフロント、モバイルアプリ、サードパーティ統合 フロー: マーチャントがアプリを承認 → Shopifyが永続的なアクセストークンを生成(シークレットローテーションなし) トークンの有効期間: 長期有効、リフレッシュ不要 セキュリティ: トークンは永続的、環境変数に安全に保存
管理インストールのセットアップ:
Shopifyパートナーダッシュボードで:
- アプリ設定 > API認証情報
- Admin APIアクセススコープで「管理インストール」を選択
- マーチャントが一度許可
- アクセストークンを環境にコピー
# .env
SHOPIFY_ADMIN_ACCESS_TOKEN=<SHOPIFY_ADMIN_ACCESS_TOKEN>
SHOPIFY_SHOP_URL=example-shop.myshopify.com
管理インストールトークンの使用:
import { GraphQLClient } from 'graphql-request';
const client = new GraphQLClient(
`https://${process.env.SHOPIFY_SHOP_URL}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || '',
},
}
);
// トークンは期限切れになりません。いつでもAPIを呼び出せます。
const query = `query { products(first: 10) { edges { node { id title } } } }`;
const products = await client.request(query);
3. OAuth 2.0 認可コードグラント(レガシー、現在もサポート)
ユースケース: 従来のOAuthフローを使用するパブリックアプリ フロー: マーチャントが「インストール」をクリック → アプリがShopify OAuthにリダイレクト → マーチャントが承認 → アプリがコードを受信 → アプリがコードをトークンと交換 トークンの有効期間: 長期有効なアクセストークン(失効しない限り期限切れなし) セキュリティ: クライアントシークレットが必要、PKCEはオプション
OAuthフロー図:
1. マーチャントがアクセス: https://your-app.com/auth
2. アプリがリダイレクト: https://your-shop.myshopify.com/admin/oauth/authorize?client_id=KEY&scope=write_products&redirect_uri=https://your-app.com/auth/callback&state=RANDOM
3. マーチャントがShopify管理画面でアプリを承認
4. Shopifyがリダイレクト: https://your-app.com/auth/callback?code=AUTHORIZATION_CODE&hmac=SIGNATURE&state=RANDOM&shop=your-shop.myshopify.com
5. アプリがHMACとstateを検証
6. アプリがコードをトークンと交換(サーバーサイド、クライアントシークレットを使用)
7. アプリがトークンをデータベースに保存
Express.js OAuthの例:
import express from 'express';
import axios from 'axios';
import crypto from 'crypto';
const app = express();
const API_KEY = process.env.SHOPIFY_API_KEY || '';
const API_SECRET = process.env.SHOPIFY_API_SECRET || '';
const REDIRECT_URI = process.env.REDIRECT_URI || 'https://your-app.com/auth/callback';
const SCOPES = 'write_products,read_products';
// ステップ1: マーチャントをShopify OAuthにリダイレクト
app.get('/auth', (req, res) => {
const shop = req.query.shop as string;
if (!shop || !shop.includes('.myshopify.com')) {
return res.status(400).send('shopパラメータが存在しないか無効です');
}
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
// 検証用にstateをセッション(またはデータベース)に保存
req.session.state = state;
req.session.nonce = nonce;
const authUrl = new URL(
`/admin/oauth/authorize`,
`https://${shop}`
);
authUrl.searchParams.append('client_id', API_KEY);
authUrl.searchParams.append('scope', SCOPES);
authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
authUrl.searchParams.append('state', state);
res.redirect(authUrl.toString());
});
// ステップ2: OAuthコールバックの処理
app.get('/auth/callback', async (req, res) => {
const { code, hmac, shop, state } = req.query;
// HMACを検証
const message = Object.entries(req.query)
.filter(([key]) => key !== 'hmac')
.map(([key, value]) => `${key}=${value}`)
.sort()
.join('&');
const hash = crypto
.createHmac('sha256', API_SECRET)
.update(message, 'utf8')
.digest('base64');
if (hash !== hmac) {
return res.status(401).send('不正なリクエストが検出されました');
}
// stateを検証
if (state !== req.session.state) {
return res.status(401).send('stateが一致しません');
}
try {
// コードをアクセストークンと交換
const response = await axios.post(
`https://${shop}/admin/oauth/access_token`,
{
client_id: API_KEY,
client_secret: API_SECRET,
code,
}
);
const { access_token, scope } = response.data;
// アクセストークンを保存(永続性のためにセッションではなくデータベースに)
await storeAccessToken(shop as string, access_token, scope);
// アプリのダッシュボードにリダイレクト
res.redirect(`/app?shop=${shop}`);
} catch (error) {
console.error('トークン交換エラー:', error);
res.status(500).send('認証に失敗しました');
}
});
// トークンを保存するヘルパー関数
async function storeAccessToken(shop: string, token: string, scope: string) {
// データベースに保存(例としてモックストレージを使用)
const db = {
shops: {} as Record<string, { token: string; scope: string }>,
};
db.shops[shop] = { token, scope };
}
app.listen(3000);
セッションストレージアダプター
アクセストークンは永続的に保存する必要があります。Shopifyは一般的なストレージバックエンド用のアダプターを提供しています:
Prisma(Remixに推奨)
スキーマ (prisma/schema.prisma):
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
accessToken String
refreshToken String?
scope String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shop, state])
@@index([shop])
}
セットアップ (shopify.app.ts):
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
// ...
sessionStorage: new PrismaSessionStorage(prisma),
});
Redis
import { RedisSessionStorage } from '@shopify/shopify-app-session-storage-redis';
import redis from 'redis';
const redisClient = redis.createClient({
host: 'localhost',
port: 6379,
});
const sessionStorage = new RedisSessionStorage({
client: redisClient,
prefix: 'shopify_session:',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
インメモリ(開発環境のみ)
import { MemorySessionStorage } from '@shopify/shopify-app-session-storage';
const sessionStorage = new MemorySessionStorage();
const shopify = shopifyApp({
// ...
sessionStorage, // 注意: アプリ再起動でセッションが失われます。開発環境のみ
});
DynamoDB
import { DynamoDBSessionStorage } from '@shopify/shopify-app-session-storage-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const dynamoDBClient = new DynamoDBClient({ region: 'us-east-1' });
const sessionStorage = new DynamoDBSessionStorage({
client: dynamoDBClient,
tableName: 'shopify-sessions',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
オンライントークンとオフライントークン
オンライントークン:
- スコープ: 現在のユーザーの権限(通常は管理ユーザー)
- 有効期限: 24時間
- ユースケース: ブラウザベースのアクション(Polaris管理画面)
- 制限: バックグラウンドジョブを実行できません。ユーザーがログアウトすると制限されます
オフライントークン:
- スコープ: アプリに付与されたスコープ
- 有効期限: なし、失効するまで永続
- ユースケース: バックグラウンドジョブ、ウェブフック、スケジュールタスク
- 制限: なし。アプリの操作にはデフォルトでこちらを使用
トークン交換でオフライントークンをリクエストする:
// shopify.app.ts
const shopify = shopifyApp({
// ...
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
});
// Remixはデフォルトで自動的にオフライントークンをリクエストします
// 特別な操作は不要です。API呼び出しにはsession.accessTokenを使用してください
ウェブフックでオフライントークンを使用する:
// webhooks/products-update.ts
export const webhooks = {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-uninstalled',
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/products-update',
},
};
export default defineWebhooksConfig(
async (request, { admin, session }) => {
const { body, query } = await graphql.query(request, {
query: GET_PRODUCT,
variables: { id: 'gid://shopify/Product/123' },
});
// session.accessTokenはオフライントークンです。ここで有効です。
console.log(`ウェブフックがショップ ${session.shop} のトークンで処理されました`);
},
webhooksConfig
);
アプリプロキシ認証
ユースケース: ストアフロント(公開)からアプリバックエンドへのリクエスト 認証方法: HMAC署名の検証(ウェブフックと同様) フロー: ストアフロント → Liquidプロキシリクエスト → アプリバックエンド(HMACを検証) → 応答
アプリプロキシの設定 (shopify.app.toml):
[[extensions]]
type = "app_proxy"
name = "Storefront API"
url = "/api/proxy"
subpath = "loyalty" # /apps/loyalty/* へのリクエストがここにルーティングされます
アプリプロキシハンドラー (Remix routes/api/proxy.ts):
import { json } from '@shopify/remix-oxygen';
import crypto from 'crypto';
export const loader = async ({ request }) => {
const url = new URL(request.url);
const hmac = url.searchParams.get('hmac') || '';
const timestamp = url.searchParams.get('_t') || '';
const shop = url.searchParams.get('shop') || '';
// リクエストパラメータからHMACを検証
const sortedParams = Object.entries(Object.fromEntries(url.searchParams))
.filter(([key]) => key !== 'hmac')
.sort()
.map(([key, value]) => `${key}=${value}`)
.join('&');
const computedHmac = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')
.update(sortedParams)
.digest('hex');
if (computedHmac !== hmac) {
return json({ error: '不正なリクエスト' }, { status: 401 });
}
// タイムスタンプの確認(リプレイ攻撃を防ぐため)
const requestTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - requestTime) > 300) { // 5分以内
return json({ error: 'リクエストの有効期限切れ' }, { status: 401 });
}
// リクエストが認証されました。応答を処理します。
const data = {
title: 'ロイヤルティポイント',
content: `ロイヤルティポイント情報`,
};
return json(data);
};
埋め込みアプリの認証
ユースケース: Shopify管理画面内で動作するPolarisアプリ 認証方法: セッショントークン + CORS + CSRF保護 フロー: マーチャントがアプリにアクセス → Shopifyがセッショントークンを検証 → 埋め込みアプリがShopify Admin APIと対話
重要な埋め込みアプリ設定:
// shopify.app.ts
const shopify = shopifyApp({
// ...
isEmbeddedApp: true,
// 埋め込みアプリは、Shopify管理画面で使用するために
// 特別なセッション処理を必要とします
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
});
CORSヘッダーの設定(server.ts):
import { createRequestHandler } from '@shopify/remix-oxygen';
export default {
async fetch(request: Request, env: Env, context: ExecutionContext) {
// 埋め込みアプリのCORSヘッダー
const response = await handleRequest(request, env, context);
response.headers.set('Content-Security-Policy',
`frame-ancestors https://*.myshopify.com https://admin.shopify.com;`
);
return response;
},
};