はじめに
個人開発のWebアプリにフリーミアム(無料/有料)の仕組みを入れたいと思ったとき、認証をどう実装するかが悩みどころです。
- サーバーを立てると費用がかかる
- クライアントサイドだけだとソースを見れば突破される
- Firebase AuthやAuth0は個人アプリには大げさ
この記事では、Cloudflare Workers(無料枠)とJWT(RS256) を組み合わせて、サーバー費用ゼロでライセンスキー認証を実装する方法を紹介します。
実際に文字なぞり練習アプリ「なぞりや」の有料機能(漢字)の解放に使っています。
アーキテクチャ
[クライアント] → キー入力 → [Cloudflare Workers /verify]
← JWT(RS256署名済み・有効期限10年) ←
[クライアント] → 公開鍵でJWT署名検証(オフライン可)→ 有料機能解放
ポイント
- 秘密鍵はCloudflare Workersの環境変数(Secrets)にのみ存在
- クライアントには公開鍵(JWK)のみ埋め込む → ソースを見ても偽造不可能
- 一度JWTを取得すればlocalStorageに保存してオフラインでも動作
- Cloudflare Workersの無料枠(10万req/日)で個人アプリなら十分
なぜRS256(非対称暗号)なのか
HS256(共通鍵)の場合、検証に使う鍵 = 署名に使う鍵なので、クライアントに鍵を埋め込むと誰でもJWTを偽造できてしまいます。
RS256(非対称暗号)なら:
| 鍵 | 場所 | 用途 |
|---|---|---|
| 秘密鍵 | Cloudflare Workers(Secrets) | JWT署名 |
| 公開鍵 | クライアント(HTML内) | JWT検証のみ |
公開鍵を埋め込んでも署名はできないため、クライアントからのJWT偽造が不可能です。
実装手順
1. RS256鍵ペアの生成
Node.jsで秘密鍵・公開鍵を生成します。
// generate_keys.js
const { generateKeyPairSync } = require('crypto');
const fs = require('fs');
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
fs.writeFileSync('private_key.pem', privateKey);
fs.writeFileSync('public_key.pem', publicKey);
// クライアント用にJWK形式でも出力
const pub = require('crypto').createPublicKey(publicKey);
const jwk = pub.export({ format: 'jwk' });
fs.writeFileSync('public_key.jwk.json', JSON.stringify(jwk, null, 2));
console.log('public_key.jwk.json をHTMLに埋め込んでください');
console.log('private_key.pem をCloudflare WorkersのSecretsに設定してください');
node generate_keys.js
生成されるファイル:
-
private_key.pem→ Cloudflare WorkersのSecretsに設定(絶対にGitにpushしない) -
public_key.jwk.json→ クライアントのHTMLに埋め込む
2. Cloudflare Workers の実装
// worker.js
const ALGORITHM = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === '/verify') {
return handleVerify(request, env);
}
return new Response('Not Found', { status: 404 });
}
};
async function handleVerify(request, env) {
try {
const { key } = await request.json();
// ライセンスキーの検証
if (!key || key.trim() !== env.LICENSE_KEY) {
return json({ ok: false, error: 'invalid_key' }, 401);
}
// JWT発行
const privateKey = await importPrivateKey(env.JWT_PRIVATE_KEY);
const token = await createJWT(privateKey);
return json({ ok: true, token }, 200);
} catch (e) {
return json({ ok: false, error: 'server_error' }, 500);
}
}
// JWT生成(RS256・有効期限10年)
async function createJWT(privateKey) {
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(JSON.stringify({
iss: 'your-app',
sub: 'premium',
iat: now,
exp: now + 60 * 60 * 24 * 365 * 10, // 10年
}));
const data = `${header}.${payload}`;
const sig = await crypto.subtle.sign(
ALGORITHM,
privateKey,
new TextEncoder().encode(data)
);
return `${data}.${b64url(sig)}`;
}
async function importPrivateKey(pem) {
const der = pemToDer(pem);
return crypto.subtle.importKey('pkcs8', der, ALGORITHM, false, ['sign']);
}
function pemToDer(pem) {
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function b64url(data) {
if (typeof data === 'string') {
return btoa(unescape(encodeURIComponent(data)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
const bytes = new Uint8Array(data);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
}
# wrangler.toml
name = "your-app-license"
main = "worker.js"
compatibility_date = "2024-01-01"
デプロイ
npm install -g wrangler
wrangler login
wrangler deploy
環境変数の設定
Cloudflare Dashboard → Workers → your-app-license → Settings → Environment Variables
# CLIから設定する場合
wrangler secret put LICENSE_KEY
# → 販売するキー文字列を入力
# PowerShellの場合
Get-Content private_key.pem -Raw | wrangler secret put JWT_PRIVATE_KEY
3. クライアント側の実装(Vanilla JS)
// public_key.jwk.json の内容を埋め込む
const JWT_PUBLIC_KEY_JWK = {
"kty": "RSA",
"n": "your-public-key-n-value",
"e": "AQAB",
"alg": "RS256",
"use": "sig"
};
const LICENSE_API = 'https://your-app-license.your-subdomain.workers.dev/verify';
const License = (() => {
const STORAGE_KEY = 'your_app_jwt';
function b64urlDecode(str) {
const b64 = str.replace(/-/g, '+').replace(/_/g, '/')
+ '=='.slice(0, (4 - str.length % 4) % 4);
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
async function getPublicKey() {
return crypto.subtle.importKey(
'jwk', JWT_PUBLIC_KEY_JWK,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false, ['verify']
);
}
// JWTの署名を検証
async function verifyJWT(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) return false;
const [header, payload, sig] = parts;
const pubKey = await getPublicKey();
const data = new TextEncoder().encode(`${header}.${payload}`);
const valid = await crypto.subtle.verify(
{ name: 'RSASSA-PKCS1-v1_5' },
pubKey,
b64urlDecode(sig),
data
);
if (!valid) return false;
// 有効期限チェック
const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return claims.sub === 'premium' && claims.exp > Date.now() / 1000;
} catch(e) { return false; }
}
let _unlocked = false;
async function init() {
try {
const token = localStorage.getItem(STORAGE_KEY);
if (token) _unlocked = await verifyJWT(token);
} catch(e) {}
}
function isUnlocked() { return _unlocked; }
// キー入力 → Workers APIで検証 → JWT保存
async function tryUnlock(key) {
try {
const res = await fetch(LICENSE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: key.trim() }),
});
const data = await res.json();
if (data.ok && data.token) {
localStorage.setItem(STORAGE_KEY, data.token);
_unlocked = await verifyJWT(data.token);
return _unlocked;
}
return false;
} catch(e) {
// オフライン時は保存済みJWTで判断
return _unlocked;
}
}
return { init, isUnlocked, tryUnlock };
})();
// 使い方
window.addEventListener('load', async () => {
await License.init(); // 起動時にJWT検証
if (License.isUnlocked()) {
// 有料機能を解放
}
});
// キー入力フォームの送信
async function onSubmitLicenseKey(key) {
const ok = await License.tryUnlock(key);
if (ok) {
console.log('解放成功!');
} else {
console.log('キーが正しくありません');
}
}
4. 動作確認
# 正しいキーで確認
curl -X POST https://your-app-license.your-subdomain.workers.dev/verify \
-H "Content-Type: application/json" \
-d '{"key":"your-license-key"}'
# 期待するレスポンス
# {"ok":true,"token":"eyJhbGciOiJSUzI1NiJ9..."}
# 誤ったキーで確認
curl -X POST https://your-app-license.your-subdomain.workers.dev/verify \
-H "Content-Type: application/json" \
-d '{"key":"wrong-key"}'
# 期待するレスポンス
# {"ok":false,"error":"invalid_key"}
セキュリティについて
防げること
- JWTの偽造: 公開鍵だけでは署名できないためRS256により防止
- キーの推測: Cloudflare Workers側でのみ検証するため直接確認不可
防げないこと(許容範囲)
- キーの共有: 購入者が第三者にキーを教えてしまうケース
- localStorageの改ざん: JWTを直接書き換えようとするケース(署名検証で防止)
- ネットワーク傍受: HTTPS使用で対策済み
個人開発のアプリであれば、このレベルのセキュリティで十分実用的です。厳密なライセンス管理が必要な場合はサーバーサイドでの都度検証が必要です。
コスト
| サービス | 用途 | 費用 |
|---|---|---|
| Cloudflare Workers | JWT発行API | 無料(10万req/日) |
| Cloudflare Pages | ホスティング | 無料 |
合計: $0/月
個人開発アプリの規模であれば無料枠で十分です。将来アクセスが増えてもWorkers Paidは$5/月と安価です。
まとめ
Cloudflare Workers × JWT(RS256)を使うことで:
- 秘密鍵をサーバーのみに保持 → クライアントに公開鍵のみ埋め込み
- 一度認証すればオフラインでも動作 → localStorageにJWTを保存して起動時に検証
- サーバー費用ゼロ → Cloudflare Workersの無料枠で運用
個人開発のフリーミアムアプリに最適な認証方式だと思います。ぜひ参考にしてみてください。