QA効率化: JWTで実現するNFCタグ・QRコードのリダイレクトシステム
TL;DR
「NFCタグやQRコードでリダイレクトしたいけど、URLを直接埋め込むのは変更できなくて不便...」「URLを短縮サービスに頼るのはセキュリティ的に心配...」
そんな悩み、JWTで解決しましょう。
Cloudflare WorkersとHonoを使ったたった33行のコードで、署名付きリダイレクトURLを管理できます。NFCタグにURLを書き込んだ後でも、リダイレクト先を自由に変更可能。しかもエッジで超高速。
// こんなにシンプル
app.get('/r', async (c) => {
const token = c.req.query('t')
const payload = await verify(token, c.env.JWT_SECRET)
return c.redirect(payload.url as string)
})
きっかけ:テスト環境ごとのURL、開くの面倒だな問題
普段はWebエンジニアとして開発したり、QAエンジニアとしてテストしたりしています。
iPhoneでWebアプリのテスト実施をしていたある日、こんな状況に遭遇しました。
「テスト環境ごとのURLを毎回ブックマークから開くの面倒だな...」
注記: 本記事で紹介するシステムは、筆者がプライベートで開発したシステムのQA業務において使用しています。勤務先企業のQA業務では使用していません。
NFCタグやQRコードって便利ですよね。スマホをかざすだけ、読み取るだけで、対象のテスト環境にアクセスできます。
でも、大きな問題があります。
短縮URLサービスという選択肢の問題点
「じゃあ、bit.lyとか短縮URLサービス使えばいいじゃん?」
確かに。でも、いくつか問題があります:
- 外部サービスへの依存: サービスが終了したら?
- セキュリティの懸念: 第三者のサービスを信頼できるか?
- 情報流出の懸念: 自社のテスト環境のURLを外部サービスに保存してもいいのか?
- コスト: 大量のURLを管理すると料金が...
「自前で安全なリダイレクトシステムを作れないかな...」
NFCタグの「紛失したらアクセスし放題」問題
NFCタグにURLを書き込むと、基本的にそれで固定されます。
- NFCタグを落としてしまったら? → テスト環境のURL変更
これ、めちゃくちゃ危険じゃないですか?
そう思ったのが、このプロジェクトの始まりでした。
このシステムでの対策
NFCタグ自体の紛失リスクに対して、このシステムでは以下の対策が可能です:
- NFCタグ紛失時: 新しいトークンを発行し、古いトークンの有効期限が切れるまで待つ
-
即座に無効化したい場合:
JWT_SECRETを変更することで全トークンを無効化(ただし正規ユーザーも再発行が必要) - 個別管理が必要な場合: ブラックリスト方式を併用(別途実装が必要)
有効期限を設定しておくことで、紛失時のリスクを最小限に抑えられます。
JWT(JSON Web Token)ってなんだ?
JWTを選んだ理由
まず、「なぜJWTなのか?」という話から。
従来のリダイレクトシステムなら、こんな構成になります:
1. データベースにURL情報を保存
2. 短縮IDを生成(例: abc123)
3. リクエストが来たらDBから検索
4. リダイレクト先を取得して転送
でも、これってデータベースが必須なんです。
そこでJWTです。JWTを使うと:
1. リダイレクト先URLをトークンに埋め込む
2. トークンに署名して改ざん防止
3. リクエストが来たら署名を検証
4. トークン内のURLへリダイレクト
データベース不要! しかも改ざんされないから安全。
JWTの仕組みを超シンプルに説明
JWTは、以下の3つの部分から構成される文字列です:
ヘッダー.ペイロード.署名
具体的な例:
eyJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tIn0.署名部分
1. ヘッダー(Header)
{
"alg": "HS256", // 使用する署名アルゴリズム
"typ": "JWT" // トークンのタイプ
}
2. ペイロード(Payload)
{
"url": "https://example.com/destination" // リダイレクト先URL
}
ここが重要! リダイレクト先のURLを直接トークンに埋め込みます。
3. 署名(Signature)
// 秘密鍵を使って署名を生成
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
この署名があるおかげで、誰かがトークンを改ざんしようとしても検知できます。
なぜJWTが安全なのか
// 悪意のある人がトークンを改ざんしようとしたら...
const tamperedToken = token.replace('example.com', 'malicious.com')
// 署名検証で弾かれる!
await verify(tamperedToken, SECRET) // → エラー!
秘密鍵(SECRET)を知らない限り、正しい署名を作れません。だから、改ざんされても検証の段階で検知できるんです。
実装:たった33行のリダイレクトシステム
使用技術スタック
- Hono - 超軽量なWebフレームワーク(Express的な)
- Cloudflare Workers - エッジで動くサーバーレス環境
- JWT - トークンベースの署名・検証
- TypeScript - 型安全な開発
プロジェクト構成
nfc-tag-redirect-manager/
├── src/
│ └── index.ts # メインのリダイレクト処理(33行)
├── generate-url.ts # 署名付きURL生成ツール(40行)
├── wrangler.jsonc # Cloudflare Workers設定
└── package.json
シンプルすぎる構成。 これだけで動きます。
メインコード(src/index.ts)
import { Hono } from 'hono'
import { verify } from 'hono/jwt'
type Bindings = {
JWT_SECRET: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/r', async (c) => {
// 1. 環境変数から秘密鍵を取得
const SECRET = c.env.JWT_SECRET
if (!SECRET) {
return c.text('JWT_SECRET not configured', 500)
}
// 2. クエリパラメータからトークンを取得
const token = c.req.query('t')
if (!token) {
return c.text('Token required', 401)
}
try {
// 3. トークンを検証してペイロードを取得
const payload = await verify(token, SECRET)
// 4. ペイロード内のURLへリダイレクト
return c.redirect(payload.url as string)
} catch (error) {
// 5. 検証失敗 = 改ざんされている
return c.text('Invalid token', 403)
}
})
export default app
たったこれだけ。 33行で安全なリダイレクトシステムの完成です。
コードの流れを詳しく解説
ステップ1: 秘密鍵の取得
const SECRET = c.env.JWT_SECRET
Cloudflare Workersの環境変数から秘密鍵を取得します。この秘密鍵はトークンの署名検証に必須です。
本番環境では、以下のコマンドで安全に設定:
wrangler secret put JWT_SECRET
ステップ2: トークンの取得
const token = c.req.query('t')
URLは https://your-worker.workers.dev/r?t=<トークン> という形式。
クエリパラメータ t からトークンを取り出します。
ステップ3: 署名の検証
const payload = await verify(token, SECRET)
verify()関数が以下を実行:
- トークンの署名部分を秘密鍵で検証
- 改ざんされていなければペイロードを返す
- 改ざんされていれば例外を投げる
ステップ4: リダイレクト実行
return c.redirect(payload.url as string)
検証に成功したら、ペイロードに含まれるURLへリダイレクト。
署名付きURL生成ツール(generate-url.ts)
リダイレクトを受け取る側のコードだけでは意味がありません。
署名付きURLを生成するツールも必要です。
import { sign } from 'hono/jwt'
const SECRET = process.env.JWT_SECRET
const BASE_URL = process.env.BASE_URL
const destination = process.argv[2]
async function generateSignedUrl() {
// 1. リダイレクト先URLをペイロードとしてトークン生成
const token = await sign({ url: destination }, SECRET)
// 2. ベースURLにトークンを付けて完成
const signedUrl = `${BASE_URL}/r?t=${token}`
console.log('✅ Signed URL generated:')
console.log(signedUrl)
}
generateSignedUrl()
使用方法
# 環境変数を設定
export JWT_SECRET="your-secret-key-here"
export BASE_URL="https://your-worker.workers.dev"
# 署名付きURLを生成(実践的な例)
# QA環境へのアクセスURL生成
npx tsx generate-url.ts https://qa-staging.example.com
# 特定フィーチャーブランチの環境
npx tsx generate-url.ts https://feature-xyz.qa.example.com
出力:
✅ Signed URL generated:
https://your-worker.workers.dev/r?t=eyJhbGciOiJIUzI1NiJ9...
このURLをNFCタグやQRコードに埋め込むだけ!
セキュリティ面での工夫
1. 改ざん検知
// もし誰かがトークンを書き換えようとしたら...
try {
await verify(tamperedToken, SECRET)
} catch (error) {
return c.text('Invalid token', 403) // 即座に拒否
}
2. 秘密鍵の安全な管理
# ❌ コードにハードコードしない
const SECRET = "my-secret-key"
# ✅ CloudFlare上の環境変数で管理
wrangler secret put JWT_SECRET
3. HTTPSの強制
Cloudflare Workersは全てHTTPSで配信されます。通信経路も暗号化されているので安心。
4. トークンの有効期限(オプション)
今回の実装には含めていませんが、必要に応じて有効期限も設定できます:
// 生成時に有効期限を設定
const token = await sign(
{
url: destination,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 // 30日間有効
},
SECRET
)
// 検証時に自動でチェックされる
const payload = await verify(token, SECRET) // 期限切れならエラー
デプロイと運用
1. Cloudflare Workersへのデプロイ
# 1. wrangler CLI のインストール(初回のみ)
npm install -g wrangler
# 2. Cloudflareにログイン
wrangler login
# 3. 秘密鍵の設定
wrangler secret put JWT_SECRET
# プロンプトでランダムな長い文字列を入力
# 4. デプロイ
npm run deploy
これだけ。 10秒程度でデプロイされます。
2. カスタムドメインの設定
Cloudflareのダッシュボードから、自分のドメインを設定できます:
your-redirect.yourdomain.com → https://your-worker.workers.dev
3. 運用コスト
Cloudflare Workersの無料枠:
- 100,000 リクエスト/日
- CPU時間 10ms/リクエスト
このシンプルなリダイレクトなら、ほぼ無料で運用できます。
JWTを使用するメリット・デメリット
メリット
-
データベース不要
- DBの構築・管理が不要
- サーバーレスで完結
- メンテナンスコストが低い
-
ステートレス
- サーバー側で状態管理しない
- スケーラビリティが高い
- 複数サーバーでも問題なし
-
改ざん検知
- 署名で改ざんを防止
- 中間者攻撃にも強い
-
シンプル
- コードが短い(33行!)
- 理解しやすい
- バグが少ない
デメリット
-
トークンの無効化が難しい
- 発行済みトークンは基本的に取り消せない
- 対策:有効期限を短くする
- 対策:ブラックリスト管理(別途実装必要)
-
トークンサイズ
- URLが長くなりがち
- NFCタグの容量制限に注意
- QRコードは問題なし
-
リダイレクト先の変更が不可
- トークン発行後は変更不可
- 対策:新しいトークンを再発行する
- 対策:用途ごとにトークンを発行
どんな時に使うべきか
JWTが向いているケース
- NFCタグ・QRコードの固定URL
- リダイレクト先が頻繁に変わらない
- シンプルさ・低コストを優先
- セキュリティが必要
JWTが向いていないケース
- リダイレクト先を頻繁に変更したい
- 発行済みURLを即座に無効化したい
- 条件分岐が複雑
→ この場合はDB管理の短縮URLシステムの方が適切
実際のQA業務での活用例
具体的にどんな場面で使えるのか、実例を紹介します。
ケース1: 複数のテスト環境を素早く切り替え
デスクにNFCタグを3つ配置:
- タグA: 開発環境 (dev)
- タグB: ステージング環境 (staging)
- タグC: QA専用環境 (qa)
iPhoneをかざすだけで、ブックマークを探す手間が省けます。テストケース実行の度に環境を切り替える必要がある場合、大幅な時短になります。
ケース2: QRコードでテスト手順書に埋め込み
テストケースごとに異なる初期状態のURLを生成し、NotionやConfluenceなどのテスト手順書にQRコードとして貼り付け。
# テストケース#1: 新規ユーザー登録画面
npx tsx generate-url.ts https://qa.example.com/signup
# テストケース#2: ログイン済み状態のダッシュボード
npx tsx generate-url.ts https://qa.example.com/dashboard?auth=test-token
テスト実施者はQRコードを読み取るだけで、正確な初期状態から検証を開始できます。
ケース3: 実機検証の効率化
10台のiPhone実機でブラウザテストする際、各端末でURLを手入力する代わりに、
- NFCタグを準備して全端末で読み取り
- QRコードを画面に表示して一斉読み取り
このように複数デバイスのテストで威力を発揮します。
推奨するNFCタグ
実際に使用しているNFCタグの情報です:
使用製品: 中華製の防水NFCタグ (NTAG215) - Amazon
NFCタグ選定のポイント
- 容量: 最低144バイト(NTAG213以上)を推奨
- 理由: JWTトークンは長くなりがち(200-400文字程度)
- 推奨モデル: NTAG213, NTAG215, NTAG216
NTAG213は144バイトの容量があり、一般的なJWTトークンであれば問題なく書き込めます。より長いトークン(有効期限やその他のメタデータを含む場合)を使う場合は、NTAG215(504バイト)やNTAG216(888バイト)を選びましょう。
今回はNTAG215(504バイト)を買いました。
よくあるトラブルと対処法
トークンが長すぎてNFCタグに書き込めない
原因: ペイロードに不要なデータが含まれている、またはNFCタグの容量不足
対処法:
- ペイロードを最小限に(urlのみ)
- 容量の大きいNFCタグ(NTAG215以上)を使用
- リダイレクト先URLを短縮(パスパラメータを削減)
Invalid tokenエラーが出る
原因: 署名検証の失敗
対処法:
-
JWT_SECRETが生成時と検証時で一致しているか確認 - トークンがコピペ時に改行や空白が入っていないか確認
- トークンの有効期限が切れていないか確認
NFCタグが読み取れない
原因: iPhoneの読み取り位置がずれている、またはNFCタグの不良
対処法:
- iPhoneの画面上部(カメラ付近)にNFCタグを近づける
- NFCタグが金属面に貼られている場合は、金属から離す
- NFCタグ自体を別のものに交換してみる
まとめ:シンプルさの中に安全性を
このプロジェクトで学んだこと:
技術面
- JWTの仕組み: 署名によるデータの保護
- Honoの軽量さ: Expressより圧倒的にシンプル
- Cloudflare Workersの手軽さ: デプロイまでが高速
設計面
- シンプルさの価値: たった33行でも実用的
- ステートレスの強み: スケーラビリティと運用コスト
- 適材適所: JWTが全てじゃない、使いどころを見極める
同じ悩みを持つ人へ
もし「テスト環境のURLも検証対象のモバイル端末も多い...」と思っているなら、このシステムを試してみてください。
重要: 業務で利用する場合は、必ず管理者や上長に確認をとってください。本記事で紹介したシステムは、筆者のプライベートプロジェクトでの使用を前提としています。
リポジトリ: nfc-tag-redirect-manager
同じ悩みを持つ人の助けになれば嬉しいです。
Discussion