はじめに
OAuthを使ったSNS連携機能を実装する際、避けて通れないのがアクセストークンの安全な保管です。
多くのチュートリアルではトークンをそのままDBに保存していますが、万一データベースが漏洩した場合、全ユーザーのSNSアカウントに不正アクセスされるリスクがあります。
本記事では、Web Crypto APIを使ってAES-256-GCMでOAuthトークンを暗号化してDBに保存する実装パターンを紹介します。Node.js / Next.js環境で外部ライブラリ不要で動作します。
なぜトークンの暗号化が必要か
OAuthフローでプロバイダーから取得するアクセストークン・リフレッシュトークンは、ユーザーのSNSアカウントへの鍵そのものです。
| リスクシナリオ | 平文保存 | 暗号化保存 |
|---|---|---|
| DBダンプの漏洩 | 全トークン流出 | 復号鍵がないと無意味 |
| SQLインジェクション | トークン取得可能 | 暗号文のみ取得 |
| バックアップ流出 | 即座に悪用可能 | 鍵なしでは復号不可 |
| 内部不正アクセス | 平文で閲覧可能 | 暗号文のみ閲覧可能 |
暗号化していれば、仮にDBの中身が漏れても復号鍵(環境変数)が別途必要なため、被害を大幅に軽減できます。
設計方針
暗号アルゴリズムの選定
| 項目 | 選定 | 理由 |
|---|---|---|
| 暗号方式 | AES-256-GCM | 認証付き暗号化(改ざん検知付き) |
| 鍵導出 | HKDF-SHA256 | 環境変数から安全に暗号鍵を生成 |
| IV長 | 96ビット(12バイト) | GCMの推奨値(NIST SP 800-38D) |
| 認証タグ | 128ビット(16バイト) | Web Crypto APIのデフォルト |
AES-256-GCMを選んだ最大の理由は、認証付き暗号化(AEAD) である点です。暗号文が改ざんされた場合、復号時にエラーが発生するため、データの完全性も保証されます。
保存フォーマット
暗号化されたトークンは以下のフォーマットでDBに保存します:
hex(iv):hex(ciphertext):hex(tag)
: 区切りの3パーツ構成です。後述する後方互換性の判定にも、この区切り文字が重要な役割を果たします。
実装
鍵導出(HKDF-SHA256)
暗号鍵を環境変数から直接使うのではなく、HKDF(HMAC-based Key Derivation Function) で導出します。
const ALGORITHM = "AES-GCM";
const KEY_LENGTH = 256;
const HKDF_INFO = "oauth-token-encryption";
let cachedKey: CryptoKey | null = null;
async function getEncryptionKey(): Promise<CryptoKey> {
if (cachedKey) return cachedKey;
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET is required for token encryption");
}
// まずシークレットをHKDFの入力素材としてインポート
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"HKDF",
false,
["deriveKey"]
);
// HKDFで暗号鍵を導出
cachedKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new TextEncoder().encode("myna.me-token-encryption"),
info: new TextEncoder().encode(HKDF_INFO),
},
keyMaterial,
{ name: ALGORITHM, length: KEY_LENGTH },
false, // extractable: false で鍵のエクスポートを禁止
["encrypt", "decrypt"]
);
return cachedKey;
}
ポイント:
- salt と info を指定することで、同じシークレットから用途別に異なる鍵を導出できます
- extractable: false で、導出された鍵をJavaScriptから読み取れないようにしています
- 鍵のキャッシュ により、リクエストごとの鍵導出コストを回避しています
暗号化(encrypt)
const IV_LENGTH = 12; // 96ビット(GCM推奨値)
export async function encryptToken(plaintext: string): Promise<string> {
const key = await getEncryptionKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv: iv.buffer as ArrayBuffer },
key,
encoded
);
// AES-GCMは暗号文の末尾に16バイトの認証タグを付加する
const encryptedBytes = new Uint8Array(encrypted);
const ciphertext = encryptedBytes.slice(0, -16);
const tag = encryptedBytes.slice(-16);
return `${toHex(iv.buffer as ArrayBuffer)}:${toHex(ciphertext.buffer as ArrayBuffer)}:${toHex(tag.buffer as ArrayBuffer)}`;
}
注意点: Web Crypto APIの encrypt() は、暗号文と認証タグを結合した状態で返します。保存時にはこれを分離して iv:ciphertext:tag の3パーツに分けています。
復号(decrypt)
export async function decryptToken(value: string): Promise<string> {
// 後方互換: ":" を含まない値はレガシーの平文トークン
if (!value.includes(":")) {
return value;
}
const parts = value.split(":");
if (parts.length !== 3) {
return value; // フォーマット不一致はそのまま返す
}
try {
const key = await getEncryptionKey();
const iv = fromHex(parts[0]);
const ciphertext = fromHex(parts[1]);
const tag = fromHex(parts[2]);
// 暗号文 + 認証タグを結合(Web Crypto APIが期待する形式)
const combined = new Uint8Array(ciphertext.length + tag.length);
combined.set(ciphertext);
combined.set(tag, ciphertext.length);
const decrypted = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv: iv.buffer as ArrayBuffer },
key,
combined.buffer as ArrayBuffer
);
return new TextDecoder().decode(decrypted);
} catch {
// 復号失敗 = たまたま ":" を含む旧平文トークンの可能性
return value;
}
}
Hex変換ユーティリティ
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
使い方
トークン保存時(OAuth callbackで)
import { encryptToken } from "@/lib/token-encryption";
// OAuthコールバックでトークンを受け取ったら暗号化して保存
const encryptedAccessToken = accessToken
? await encryptToken(accessToken)
: null;
const encryptedRefreshToken = refreshToken
? await encryptToken(refreshToken)
: null;
await db.insert(connectedAccounts).values({
userId,
provider,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
// ...
});
トークン利用時(API呼び出しで)
import { decryptToken } from "@/lib/token-encryption";
// DBから取得した暗号化トークンを復号してAPI呼び出し
const account = await db.query.connectedAccounts.findFirst({
where: eq(connectedAccounts.userId, userId),
});
const token = await decryptToken(account.accessToken);
// 復号されたトークンでAPIを呼び出し
const response = await fetch("https://api.github.com/user/repos", {
headers: { Authorization: `Bearer ${token}` },
});
後方互換性の設計
既存の平文トークンと暗号化トークンを共存させるために、以下の判定ロジックを組み込んでいます:
値に ":" が含まれない → 旧平文トークン → そのまま返す
値に ":" が含まれる → パーツ数を確認
→ 3パーツ → 暗号化トークンとして復号を試行
→ 3パーツ以外 → そのまま返す
復号失敗 → たまたま ":" を含む平文として返す
この設計により、マイグレーション不要で暗号化を段階的に導入できます。新規保存分から暗号化され、旧データは復号時に自動判別されます。
運用上の注意点
1. AUTH_SECRETの管理
暗号鍵はAUTH_SECRETから導出されるため、この環境変数が漏洩すると全トークンが復号可能になります。
- 環境変数は必ずシークレット管理サービスで管理する
- ログに出力しない
- ソースコードにハードコードしない
2. AUTH_SECRETのローテーション
AUTH_SECRETを変更すると、既存の暗号化トークンがすべて復号不能になります。ローテーションが必要な場合は:
- 旧シークレットで全トークンを復号
- 新シークレットで再暗号化
- DBを一括更新
- 環境変数を切り替え
3. IVの一意性
AES-GCMでは同じ鍵でIVを再利用すると安全性が崩壊します。本実装では crypto.getRandomValues() で暗号学的に安全な乱数を生成しているため、衝突の確率は無視できるレベルです。
まとめ
| 項目 | 内容 |
|---|---|
| アルゴリズム | AES-256-GCM(認証付き暗号化) |
| 鍵導出 | HKDF-SHA256(環境変数から導出) |
| 外部依存 | なし(Web Crypto APIのみ) |
| 後方互換 | 旧平文トークンと自動共存 |
| フォーマット | hex(iv):hex(ciphertext):hex(tag) |
Web Crypto APIはNode.js 20+でネイティブサポートされており、外部ライブラリなしでプロダクションレベルの暗号化が実装できます。OAuthトークンに限らず、APIキーやシークレットの保存にも同じパターンが適用可能です。
この実装は myna.me というOAuth認証済みリンクプロフィールサービスで実際に使用しています。16のOAuthプロバイダーのアクセストークン・リフレッシュトークンの暗号化保存に活用しています。