広告運用プラットフォーム(Google Ads/Meta/TikTok/X/LINE)のAPI連携を社内ツールに組み込む際、最大の設計課題が「顧客ごとのアクセストークン/リフレッシュトークン/デベロッパートークンをどう保管するか」だ。平文でDBに置けば事故一発で全顧客のアカウントが危険にさらされる。本記事ではrenueが社内マルチテナント基盤で採用しているFernet対称鍵暗号 + SQLAlchemy型変換レイヤーによる認証情報永続化パターンを、実装コードと共に解説する。
なぜ「平文でDBに保存」がダメなのか
- DB漏洩即破滅: MySQLダンプ1つで全顧客のAPIアカウントが第三者に使える状態になる
- バックアップ拡散: 定期バックアップ・レプリカ・開発環境コピーに平文が残る
- ログ混入リスク: クエリログや
str(model)で平文が吐き出される事故 - 開発者の過剰権限: アプリ開発者が本番DBを覗けると、顧客の広告アカウントを閲覧できてしまう
- コンプライアンス: プライバシーマーク/ISMS/SOC2 監査で「暗号化していない」は即指摘
一方で「KMS/Vaultを1発導入」は中小SaaSには重すぎる。本記事のゴールは「Pythonのcryptographyライブラリだけで、重厚なインフラ追加なしに」認証情報を安全に永続化する現実解を提示することだ。
Fernet: 何を選んで何を捨てるか
Fernetはcryptographyライブラリが提供する対称鍵認証付き暗号化スキームだ。内部的にはAES-128-CBC + HMAC-SHA256で構成されている。
| 特性 | Fernetの採用 |
|---|---|
| アルゴリズム | AES-128-CBC + HMAC-SHA256 (認証付き暗号) |
| 鍵長 | 32バイト(256ビット) URL-safe base64 |
| IV | 暗号化ごとにランダム生成(自動) |
| トークン形式 | version + timestamp + IV + ciphertext + HMAC |
| 改ざん検知 | あり(HMAC) |
| 鍵ローテーション | MultiFernetで複数鍵同時対応可 |
選ばなかった選択肢
- 自前AES実装: IVとHMACの実装ミスが命取り — 選ばない
- MySQL AES_ENCRYPT関数: 鍵がSQLログに漏れる・ECBモードで弱い
- Azure Key Vault直接: 各リクエストでKMSコール発生=コスト/レイテンシ増
- pgp_sym_encrypt (PostgreSQL): MySQL環境では使えない
レイヤー1: 極小のcipherラッパー(30行)
認証情報暗号化のコア実装は驚くほどシンプルで、実用上30行で十分だ。
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from src.setting import env_setting
def _get_cipher() -> Fernet:
key = env_setting.CREDENTIALS_ENCRYPTION_KEY
if not key:
raise ValueError("CREDENTIALS_ENCRYPTION_KEY is not set.")
return Fernet(key)
def encrypt_value(value: Optional[str]) -> Optional[str]:
if value is None:
return None
cipher = _get_cipher()
return cipher.encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_value(value: Optional[str]) -> Optional[str]:
if value is None:
return None
cipher = _get_cipher()
try:
return cipher.decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise ValueError("Failed to decrypt credential value.") from exc
設計上の勘所
- None透過:
Noneが来たらNoneを返す — DBのNULLableカラムと整合 - 鍵は毎回env_settingから取る: 将来KMS経由で動的取得に置き換えやすい
- InvalidTokenをValueErrorにラップ: 呼び出し側の例外処理を薄くできる
- UTF-8固定: 日本語文字列(account_name等)も暗号化対象になるため明示
- モジュールレベル関数で提供: クラス化するほどの状態がない — Fernetインスタンスはステートレス
レイヤー2: 鍵生成とenv_setting配置
Fernet鍵の生成は1コマンドで済む。
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
出力例(44文字のurl-safe base64):
nXbQj7vV6Wl8F-2cE9kY4xI5pR1aT3zQmS6oU8gK2hM=
これをCREDENTIALS_ENCRYPTION_KEYとして環境変数に設定する。
環境別の鍵管理戦略
| 環境 | 鍵の保管場所 | 配布方法 |
|---|---|---|
| ローカル開発 | .env.local (gitignore) | 1Password等で共有 |
| CI | GitHub Actions Secrets | 組織管理者のみアクセス |
| ステージング | Azure Key Vault → App Service環境変数 | Managed Identity経由 |
| 本番 | Azure Key Vault → App Service環境変数 | 限定ロールのみ閲覧可 |
重要なのは環境ごとに別の鍵を使うこと。本番の鍵で暗号化したデータを開発環境にコピーしても復号できない設計にすると、バックアップからの情報漏洩リスクを最小化できる。
レイヤー3: 複数広告プラットフォームをまたぐ共通利用
この暗号化ヘルパーの真価は「5広告プラットフォームで同一パターンを使い回せる」点だ。Google Ads/Meta/TikTok/X/LINE Adsそれぞれに別実装を書く必要がない。
# Google Ads ルーター
from src.shared.lib.credential_cipher import decrypt_value
from src.shared.lib.google_ads_client import GoogleAdsClient
account = _resolve_platform_account(db, project_id, account_id)
try:
access_token = decrypt_value(account.access_token) if account.access_token else None
refresh_token = decrypt_value(account.refresh_token) if account.refresh_token else None
developer_token = decrypt_value(account.developer_token) if account.developer_token else None
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# MCC階層もサポート
if not refresh_token and mcc.refresh_token:
refresh_token = decrypt_value(mcc.refresh_token)
if not developer_token and mcc.developer_token:
developer_token = decrypt_value(mcc.developer_token)
client = GoogleAdsClient(
access_token=access_token,
refresh_token=refresh_token,
developer_token=developer_token,
)
Meta/TikTok/X/LINEのルーターも全く同じdecrypt_value(account.xxx)パターン。読みやすさと監査性が両立する。
保存時のパターン(create/update)
from src.shared.lib.credential_cipher import encrypt_value
account = ModelAdPlatformAccount(
project_id=project_id,
platform="google_ads",
access_token=encrypt_value(request.access_token),
refresh_token=encrypt_value(request.refresh_token),
developer_token=encrypt_value(request.developer_token),
account_name=request.account_name, # 平文(識別用)
)
db.add(account)
db.commit()
認証情報カラムは常にencrypt_value()を通す。識別用のaccount_name等は平文という使い分けが実運用で重要になる(検索・UI表示で必要)。
レイヤー4: SQLAlchemy TypeDecoratorで完全自動化(発展形)
毎回encrypt_value/decrypt_valueを書くのを忘れると致命的な事故になる。発展形としてSQLAlchemy TypeDecoratorで暗号化を型レベルに埋め込む設計がある。
from sqlalchemy.types import TypeDecorator, String
from src.shared.lib.credential_cipher import encrypt_value, decrypt_value
class EncryptedString(TypeDecorator):
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
return encrypt_value(value)
def process_result_value(self, value, dialect):
return decrypt_value(value)
class AdPlatformAccount(Base):
__tablename__ = "ad_platform_accounts"
id = Column(Integer, primary_key=True)
access_token = Column(EncryptedString(1024))
refresh_token = Column(EncryptedString(1024))
developer_token = Column(EncryptedString(1024))
account_name = Column(String(255)) # 平文
この設計だとアプリケーションコード側は暗号化の存在を意識しない。SELECT時に自動復号、INSERT/UPDATE時に自動暗号化される。ただしLIKE検索や完全一致検索ができなくなる副作用があるため、検索対象のカラムには使わない。
レイヤー5: 鍵ローテーション(MultiFernet)
「鍵は半年〜1年で交換する」のがセキュリティベストプラクティスだ。FernetはMultiFernetで複数鍵を同時に扱えるため、ダウンタイムゼロの鍵交換が可能。
from cryptography.fernet import Fernet, MultiFernet
# 新しい鍵を先頭に、古い鍵を後ろに
old_key = env_setting.CREDENTIALS_ENCRYPTION_KEY_OLD
new_key = env_setting.CREDENTIALS_ENCRYPTION_KEY
multi = MultiFernet([Fernet(new_key), Fernet(old_key)])
# 暗号化は常に先頭の鍵(新鍵)で行われる
ciphertext = multi.encrypt(plaintext.encode("utf-8"))
# 復号は先頭の鍵から順に試される(新→旧でフォールバック)
plaintext = multi.decrypt(ciphertext).decode("utf-8")
鍵ローテーションの5段階フロー
- 新鍵生成:
Fernet.generate_key()で新鍵を作る - MultiFernetに追加:
[new_key, old_key]の順で両方サポート状態に - rewrap バッチ: 既存DB行を順次読み出し→復号→新鍵で再暗号化して更新
- 旧鍵除去: 全件rewrap完了後、旧鍵を環境変数から削除
- MultiFernet撤去: 単一鍵運用に戻す
# rewrap バッチ例
def rewrap_all_credentials(db: Session, multi: MultiFernet):
accounts = db.query(AdPlatformAccount).all()
for account in accounts:
if account.access_token:
plaintext = multi.decrypt(account.access_token.encode()).decode()
account.access_token = multi.encrypt(plaintext.encode()).decode()
# refresh_token, developer_tokenも同様
db.commit()
rewrap処理は深夜メンテ枠で回し、進捗はログに記録しておくと安心だ。
よくある設計ミスと対策
| ミス | 影響 | 対策 |
|---|---|---|
| 鍵をgit管理 | リポジトリ全体が危険 | .gitignore + pre-commit hook |
| 鍵を全環境共通 | 開発DBコピーから本番漏洩 | 環境別鍵 + 定期ローテ |
| 鍵をログに出力 | ログ基盤経由で漏洩 | ロガーのredact設定 |
| 復号結果をキャッシュ | メモリダンプで漏洩 | 必要な時だけ復号・破棄 |
| exception messageに平文 | エラー監視SaaS経由で漏洩 | exceptionは汎用メッセージのみ |
KMS/Vaultに移行する判断基準
本記事のFernet + env_varパターンは「スタートアップ〜中規模SaaS」に最適解だが、以下の状況ではAzure Key Vault / AWS KMS / HashiCorp Vault への移行を検討すべき。
- SOC2 Type 2/ISO 27001取得が必要: 鍵管理の監査ログが要求される
- 開発者が50人を超えた: 鍵配布の人間リスクが無視できない
- マルチリージョン展開: リージョンごとに鍵管理が必要
- Hardware Security Module要件: FIPS 140-2等のコンプラ要件がある
- 自動ローテーションを組み込みたい: MultiFernet手運用は限界がある
まとめ
- 30行のFernetラッパーで広告5社分の認証情報を統一パターンで暗号化できる
- None透過 + UTF-8固定 + InvalidToken→ValueErrorラップでAPIが綺麗になる
- 環境別鍵で開発DBコピーからの情報漏洩を防ぐ
- TypeDecorator発展形で暗号化を型に埋め込み、実装ミスをゼロにする
- MultiFernetでダウンタイムゼロ鍵ローテーションを実現
- KMS/Vault移行判断はスケール/コンプラ要件を基準に
よくある質問
Q. Fernet鍵はどのくらいの頻度でローテーションすべき?
一般的には6ヶ月〜1年に1回。社員の離職時/セキュリティインシデント発生時には即座に実施。
Q. 暗号化カラムのインデックスは効く?
効かない。Fernet暗号化は同じ平文でも毎回異なる暗号文になる(IVランダム)ため、完全一致検索もできない。検索が必要なカラム(account_name等)は平文で別カラムに保持する。
Q. Fernet以外の選択肢は?
AWS環境ならKMS Client-Side Encryption、Azure環境ならKey Vault + Managed HSM、全部入りならHashiCorp Vault Transit Engine。ただし初期導入コストと運用複雑度を考慮すると、中小SaaSではまずFernetで始めるのが現実的。
