renue

ARTICLE

FastAPIマルチテナント基盤の認証情報暗号化パターン【2026年版】— Fernet×広告5社連携×鍵ローテーションの実装ガイド

公開日: 2026/4/6

広告運用プラットフォーム(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等で共有
CIGitHub 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段階フロー

  1. 新鍵生成: Fernet.generate_key()で新鍵を作る
  2. MultiFernetに追加: [new_key, old_key]の順で両方サポート状態に
  3. rewrap バッチ: 既存DB行を順次読み出し→復号→新鍵で再暗号化して更新
  4. 旧鍵除去: 全件rewrap完了後、旧鍵を環境変数から削除
  5. 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手運用は限界がある

まとめ

  1. 30行のFernetラッパーで広告5社分の認証情報を統一パターンで暗号化できる
  2. None透過 + UTF-8固定 + InvalidToken→ValueErrorラップでAPIが綺麗になる
  3. 環境別鍵で開発DBコピーからの情報漏洩を防ぐ
  4. TypeDecorator発展形で暗号化を型に埋め込み、実装ミスをゼロにする
  5. MultiFernetでダウンタイムゼロ鍵ローテーションを実現
  6. 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で始めるのが現実的。