---
title: "PrefectのSecretブロックは暗号鍵の管理で注意していること"
description: "Prefect の Secret ブロックは、環境変数で鍵を渡さない限り、暗号化に使う Fernet 鍵を暗号文と同じ SQLite に平文で書きます。ドキュメントには「保存時に暗号化される」としかありません。ソースと実機で確かめた内容と、社内の自動化基盤で AI エージェント用の資格情報を置くときに決めた 2 つのルールを書きます。"
category: "AI"
tags: ["Prefect","Security","Python"]
publishedAt: "2026-09-16"
lastmod: "2026-09-16"
---

Prefect の `Secret` ブロックは、`PREFECT_SERVER_ENCRYPTION_KEY` を渡さないと、暗号化に使う Fernet 鍵を暗号文と同じ SQLite ファイルの `configuration` テーブルに平文で書いてしまいます。ドキュメントには「保存時に暗号化される」としか書かれておらず、鍵の置き場は出てきません。社内の自動化基盤で AI エージェント用の資格情報をどこに置くかを決めるときに、ソースを読んで実機で確かめた記録です。

## 「暗号化される」の中身をソースで読む

[Prefect のドキュメント](https://docs.prefect.io/v3/develop/secrets)は「Secret values are encrypted at rest when stored in your Prefect backend.」と書いています。鍵をどう管理するかは書かれていません。[セキュリティ設定のページ](https://docs.prefect.io/v3/advanced/security-settings)も Basic 認証、CSRF、CORS、リバースプロキシまでで、暗号鍵については触れられていませんでした。

インストール済みの Prefect 3.8.4 では、`Secret.save()` の値はサーバー側の `server/models/block_documents.py` で `encrypt_data` に渡され、`server/utilities/encryption.py` で Fernet 暗号化されます。鍵を決めているのは同じファイルの `_get_fernet_encryption` です。

```python
environment_key = os.getenv(
    "PREFECT_SERVER_ENCRYPTION_KEY",
    os.getenv("ORION_ENCRYPTION_KEY"),
)
if environment_key:
    return Fernet(environment_key.encode())

configured_key = await configuration.read_configuration(session, "ENCRYPTION_KEY")

if configured_key is None:
    encryption_key = Fernet.generate_key()
    configured_key = schemas.core.Configuration(
        key="ENCRYPTION_KEY", value={"fernet_key": encryption_key.decode()}
    )
    await configuration.write_configuration(session, configured_key)
```

環境変数があればそれを使い、なければ鍵を生成して `configuration` テーブルに書きます。書き出し先は暗号文が入る `block_document` と同じデータベースです。

## SQLite の中身を見る

読んだだけでは信用しないことにしているので、使い捨ての `PREFECT_HOME` を切って ephemeral server で 1 件保存し、SQLite を直接見てみます。

```python
from prefect.blocks.system import Secret
Secret(value="dummy").save("probe", overwrite=True)
```

環境変数を渡さずに実行した結果は、次のようになりました(鍵は検証用の使い捨てで、省略して載せています)。

```text
$ sqlite3 home/prefect.db "select name, substr(data,1,12), length(data) from block_document;"
probe|"gAAAAABqnOe|122

$ sqlite3 home/prefect.db "select key, value from configuration;"
ENCRYPTION_KEY|{"fernet_key": "1b3yegm0…7c="}
```

`block_document.data` は `gAAAAA` で始まる Fernet の暗号文になっています。その隣の `configuration` テーブルには、復号に必要な鍵がそのまま入っています。SQLite の DB ファイルを読める相手には、復号に必要な鍵ごと見えている状態です。

`PREFECT_SERVER_ENCRYPTION_KEY` に鍵を渡して同じことをすると、`block_document.data` は同じ形の暗号文のまま、`configuration` テーブルは 0 行でした。鍵はデータベースに書かれません。

![環境変数を渡さない場合は prefect.db の中に block_document の暗号文と configuration の平文鍵が並ぶ。環境変数で鍵を渡す場合は prefect.db に暗号文だけが残り、鍵はプロセスの環境にある](/images/posts/prefect-secret-block-encryption-key/key-location.svg)

## 鍵は launchd の環境変数で渡す

決めたのは 2 つです。

1 つ目は、`PREFECT_SERVER_ENCRYPTION_KEY` を必ず明示することです。
Prefect サーバーを起動する launchd の LaunchAgent に `EnvironmentVariables` として鍵と `PREFECT_API_URL` を書けば非対話の子プロセスまで届くことを、使い捨ての LaunchAgent で実際に読み出して確かめました。忘れると何のエラーも出ずに「鍵と暗号文が同じファイル」に戻るので、プロビジョニング手順に組み込んでいます。

2 つ目は、`Secret.load()` を呼ぶのはフローのコードだけにして、エージェントに登録するツールからは決して呼ばないことです。
エージェントから呼べるものは、プロンプト次第で外に出せるものになるからです。Prefect OSS の認証は、`server/api/server.py` で `PREFECT_SERVER_API_AUTH_STRING` の 1 本の文字列を `hmac.compare_digest` で比べるだけで、主体やリソースで分岐する箇所がありません。ブロックの読み取り API も `include_secrets` をリクエストボディで受け取るだけです。実装を読む限り、ブロック単位で読める相手を制限する仕組みは OSS 側にはないので、境界はコードの書き方で守るしかありません。

![AI エージェントはツールを呼び、ツールはフローを起動し、フローが Secret.load() で Prefect の API から値を取り出す。境界はツールとフローの間にあり、エージェントとツールの層では Secret.load() を呼ばない。Prefect OSS の API は 1 本の認証文字列を比べるだけで主体やブロック単位で分けられないので、境界を守るのはコードの書き方だけになる](/images/posts/prefect-secret-block-encryption-key/load-boundary.svg)

登録は値を標準入力から受け取る短いスクリプトにしました。`prefect block create` は値を受け取れず UI のリンクを出すだけで、CLI 引数で渡すとシェルの履歴に残るからです。

```python
import sys
from prefect.blocks.system import Secret

Secret(value=sys.stdin.readline().rstrip("\n")).save(sys.argv[1], overwrite=True)
```

```bash
read -rs TOKEN && printf '%s\n' "$TOKEN" | uv run python register_secret.py x-access-token && unset TOKEN
```

ブロックは 1 つの資格情報につき 1 つです。`Secret.get()` は保存した文字列に `json.loads` をかけるので、複数の値を JSON っぽい形で詰めると `str` のつもりが別の型で返ってきます。

## この方式で守れる範囲

環境変数の鍵は plist に平文で書かれるので、守れているのは「データベースのファイルだけを読まれた場合」までです。plist をラッパースクリプトからの注入に変えるかどうかは、サーバーの常駐方法と一緒に決めます。
またこの方式では、マシンに侵入されている場合の保護はかなり限定的で、鍵が同じ DB にあるよりは多少マシという程度です。

外部サービスへ投稿できる資格情報を持つ役が 2 つに増えたら、同じ basic auth の内側に別の役の秘密が並ぶことになり、コードの書き方だけで分けている境界が、資格情報の側では成り立たなくなります。そのときは 1Password の Service Account など、別の仕組みを検討することになります。