How the Open-Notebook Credential Management System Stores API Keys with Fernet Encryption

Open-Notebook encrypts every AI-provider API key at rest using Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256) by deriving a key from the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable, automatically encrypting secrets on save and decrypting them on load through the Credential domain model.

The lfnovo/open-notebook repository implements a secure credential management system that ensures API keys never persist in plaintext in the SurrealDB database. By integrating Fernet symmetric encryption directly into the Credential model's persistence lifecycle in open_notebook/domain/credential.py, the system protects sensitive secrets at rest while allowing developers to work with standard Pydantic SecretStr objects throughout the application.

Fernet Encryption Workflow

The credential management system operates through a transparent three-phase workflow that encrypts data before database insertion and decrypts it upon retrieval. This process ensures that raw API keys exist only in memory during active use.

Key Derivation from Environment Variables

The encryption subsystem initializes by reading the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable (or its Docker Secret variant suffixed with _FILE). In open_notebook/utils/encryption.py, the _ensure_fernet_key() function processes this value by hashing it with SHA-256 and encoding the result as a URL-safe Base64 string, which conforms to Fernet's required 32-byte key format.

Source: _ensure_fernet_key in [open_notebook/utils/encryption.py](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/utils/encryption.py#L5-L13)

Encryption During Persistence

When a Credential instance is persisted via save(), the private _prepare_save_data() method intercepts the api_key field—which is stored internally as a Pydantic SecretStr. The method extracts the secret value and passes it to encrypt_value() in open_notebook/utils/encryption.py, which returns a Fernet token (a URL-safe Base64 string). This ciphertext is what actually gets written to the credential table.

Source: _prepare_save_data handling of api_key in [open_notebook/domain/credential.py](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/domain/credential.py#L34-L40) and [encrypt_value() in utils/encryption.py](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/utils/encryption.py#L28-L43)

Decryption on Retrieval

Whenever credentials are loaded via Credential.get(), Credential.get_all(), or the internal _from_db_row() constructor, the system checks if the stored api_key value resembles a Fernet token. If so, it passes the ciphertext to decrypt_value(), which uses the same environment-derived key to restore the plaintext. The result is wrapped back into a SecretStr before being returned to the caller.

If the stored value is plaintext (legacy data), it is returned unchanged. If decryption fails due to a key mismatch, a clear ValueError is raised.

Sources: Credential.get() logic in [open_notebook/domain/credential.py lines 58‑70](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/domain/credential.py#L58-L70), Credential.get_all() in lines 73‑95, and [decrypt_value() in utils/encryption.py](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/utils/encryption.py#L66-L78)

Practical Implementation Examples

The encryption layer operates transparently during credential creation and retrieval. The following examples demonstrate the developer-facing API and the underlying utility functions.

Creating and Saving an Encrypted Credential

When you instantiate a Credential and call save(), the encryption happens automatically before the database write:

from open_notebook.domain.credential import Credential
from pydantic import SecretStr

# Define a credential – the raw key is never stored plain-text

cred = Credential(
    name="My OpenAI Key",
    provider="openai",
    modalities=["language", "embedding"],
    api_key=SecretStr("sk-my-super-secret-key"),
)

# Persist – _prepare_save_data() encrypts the key before the DB write

await cred.save()

The value stored in SurrealDB resembles a long Base64 string (e.g., gAAAAABlY...), not the raw API key.

Retrieving and Decrypting Credentials

When loading credentials, decryption occurs transparently, returning a SecretStr containing the original value:


# Load a credential by its SurrealDB record ID

cred = await Credential.get("credential:12345")

# The api_key field is again a SecretStr containing the clear text

print(cred.api_key.get_secret_value())   # → "sk-my-super-secret-key"

Low-Level Encryption Utilities

For custom utilities or data migrations, you can access the core encryption functions directly from the utilities module:

from open_notebook.utils.encryption import encrypt_value, decrypt_value

plain = "sk-my-super-secret-key"
cipher = encrypt_value(plain)   # → Fernet token (Base64)

assert decrypt_value(cipher) == plain

Handling Key Rotation Errors

If the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable changes after credentials are encrypted, subsequent retrieval attempts will fail with a descriptive error:

try:
    cred = await Credential.get("credential:12345")
except ValueError as exc:
    # Raised when the stored token cannot be decrypted because the env key changed

    print("Decryption failed:", exc)

Summary

The Open-Notebook credential management system implements a robust security architecture for API key storage:

  • Environment-based key derivation utilizes SHA-256 hashing of the OPEN_NOTEBOOK_ENCRYPTION_KEY variable to produce Fernet-compatible encryption keys in _ensure_fernet_key().
  • Automatic encryption occurs in _prepare_save_data() within open_notebook/domain/credential.py, converting SecretStr values to Fernet tokens before database persistence via encrypt_value().
  • Transparent decryption happens during credential retrieval via Credential.get() and Credential.get_all(), ensuring application code always receives usable SecretStr objects while raw ciphertext never leaves the database layer.
  • Legacy compatibility allows the system to handle unencrypted plaintext values gracefully during transitions to encrypted storage, while strict error handling prevents silent failures when encryption keys mismatch.

Frequently Asked Questions

What encryption algorithm does Fernet use?

Fernet implements AES-128-CBC for symmetric encryption combined with HMAC-SHA256 for authentication. This construction ensures both confidentiality and integrity of the stored API keys, preventing unauthorized decryption or tampering even if the database is compromised.

How is the encryption key configured?

The system reads the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable on startup. For Docker deployments, you can use the _FILE variant (e.g., OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE) to load the key from a mounted secret file. The _ensure_fernet_key() function in open_notebook/utils/encryption.py hashes the input with SHA-256 to generate the final 32-byte Fernet key required by the cryptography library.

What happens if the encryption key changes?

If the OPEN_NOTEBOOK_ENCRYPTION_KEY is modified after credentials have been encrypted, attempts to retrieve those credentials will raise a ValueError. The decrypt_value() function detects invalid tokens and fails loudly, preventing the application from using unrecoverable data. To recover, you must either restore the original key value or regenerate the credentials and re-encrypt them with the new key.

How does the system handle legacy unencrypted data?

When loading credentials, the code checks whether the stored value resembles a valid Fernet token. If the value appears to be plaintext (legacy data that predates encryption implementation), it is returned unchanged wrapped in a SecretStr. This backward-compatible approach allows gradual migration to encrypted storage without breaking existing deployments.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →