Back to 0fee
0fee

Encryption and Credential Management for Payment Providers

How 0fee.dev encrypts payment provider credentials with Fernet/AES, manages per-app secrets, and what the security audit found. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 10 min 0fee
encryptionsecuritycredentialsfernetaespayment-providers

When a merchant connects their Stripe account to 0fee.dev, they submit their Stripe secret key. When they connect PaiementPro, they submit their merchant ID and API key. When they connect Hub2, they submit an API key and a subscription key. These credentials are the keys to the merchant's payment processing capability. If they leak, an attacker can initiate payments, view transaction data, or steal funds. Encrypting these credentials at rest is not optional -- it is the baseline security requirement for a payment orchestrator.

The Problem: Multi-Tenant Credential Storage

0fee.dev is multi-tenant. Each merchant app has its own set of provider credentials:

App: "Deblo Production" (app_live_abc123)
  ├── Stripe: api_key, webhook_secret
  ├── PaiementPro: merchant_id, api_key
  └── Hub2: api_key, subscription_key

App: "WorkCloud" (app_live_def456)
  ├── Stripe: api_key, webhook_secret
  ├── PayPal: client_id, client_secret
  └── PawaPay: api_key

Each credential set must be:

  1. Encrypted at rest -- plaintext credentials never stored in the database.
  2. Decryptable at runtime -- the system must decrypt credentials when initiating a payment.
  3. Isolated per app -- one merchant's credentials must never be accessible to another.
  4. Manageable -- merchants can add, update, and remove credentials through the dashboard.

Fernet/AES Encryption

The encryption service uses Python's cryptography library with Fernet symmetric encryption, which is built on AES-128-CBC with HMAC-SHA256 authentication:

python# services/encryption.py
import base64
import json
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

from config import settings

def _derive_key(encryption_key: str) -> bytes:
    """
    Derive a Fernet-compatible key from the master encryption key
    using PBKDF2.
    """
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=b"0fee.dev.salt.v1",
        iterations=100_000,
    )
    key_bytes = kdf.derive(encryption_key.encode("utf-8"))
    return base64.urlsafe_b64encode(key_bytes)

def encrypt_credentials(credentials: dict) -> str: """ Encrypt a credentials dictionary. BLANK Args: credentials: Dict of provider credentials e.g., {"api_key": "sk_live_...", "webhook_secret": "whsec_..."} BLANK Returns: Base64-encoded encrypted string. """ key = _derive_key(settings.ENCRYPTION_KEY) f = Fernet(key) BLANK plaintext = json.dumps(credentials).encode("utf-8") encrypted = f.encrypt(plaintext) BLANK return encrypted.decode("utf-8") BLANK

def decrypt_credentials(encrypted_str: str) -> dict: """ Decrypt an encrypted credentials string. BLANK Args: encrypted_str: Base64-encoded encrypted string from the database. BLANK Returns: Dict of decrypted provider credentials. """ key = _derive_key(settings.ENCRYPTION_KEY) f = Fernet(key) BLANK decrypted = f.decrypt(encrypted_str.encode("utf-8")) BLANK return json.loads(decrypted.decode("utf-8")) ```

Why Fernet?

Fernet was chosen over raw AES for several reasons:

FeatureFernetRaw AES
Authenticated encryptionBuilt-in (HMAC-SHA256)Manual (separate MAC needed)
IV/nonce managementAutomaticManual
TimestampIncluded (for token expiry)Not included
Implementation riskLow (high-level API)High (many pitfalls)
PerformanceAdequate for credential encryptionFaster but unnecessary

Fernet handles initialization vectors, authentication, and padding internally. This eliminates entire categories of cryptographic implementation errors. For a payment platform where the encryption must be correct -- not just fast -- Fernet's safety guarantees are worth the small performance overhead.

PBKDF2 Key Derivation

The master encryption key from the environment variable (ENCRYPTION_KEY) is not used directly. Instead, it is processed through PBKDF2 (Password-Based Key Derivation Function 2) with 100,000 iterations. This serves two purposes:

  1. Key stretching. If the master key has low entropy (e.g., a short passphrase), PBKDF2 makes brute-force attacks computationally expensive.
  2. Key normalization. Fernet requires exactly 32 bytes of key material. PBKDF2 produces exactly that, regardless of the input key length.

Provider Credential Structure

Each provider has a different set of required credentials:

ProviderCredentials
Stripeapi_key, webhook_secret
PayPalclient_id, client_secret
Hub2api_key, subscription_key
PawaPayapi_key
BUIapi_key, webhook_secret
PaiementPromerchant_id, api_key
Test(none -- no real credentials needed)

The credential structure is stored as a JSON dictionary, encrypted as a single blob:

python# Example: storing Stripe credentials
credentials = {
    "api_key": "sk_live_51234567890abcdef",
    "webhook_secret": "whsec_abcdef123456",
}

encrypted = encrypt_credentials(credentials)
# Result: "gAAAAABl..." (base64 Fernet token)

# Store in database
with get_db() as conn:
    conn.execute(
        """
        INSERT INTO provider_credentials
            (id, app_id, provider_id, environment,
             encrypted_credentials, is_active, created_at)
        VALUES (?, ?, ?, ?, ?, 1, ?)
        """,
        (cred_id, app_id, "stripe", "production",
         encrypted, datetime.utcnow().isoformat())
    )

Credential Retrieval at Payment Time

When a payment is initiated, the routing engine determines which provider to use, and the system retrieves and decrypts that provider's credentials:

pythonasync def get_decrypted_credentials(
    app_id: str,
    provider_id: str,
    environment: str,
) -> dict:
    """
    Retrieve and decrypt provider credentials for an app.

    Args:
        app_id: The merchant's app ID
        provider_id: e.g., "stripe", "hub2"
        environment: "production" or "sandbox"

    Returns:
        Dict of decrypted credentials.

    Raises:
        HTTPException(404) if no credentials found.
    """
    with get_db() as conn:
        row = conn.execute(
            """
            SELECT encrypted_credentials
            FROM provider_credentials
            WHERE app_id = ?
              AND provider_id = ?
              AND environment = ?
              AND is_active = 1
            """,
            (app_id, provider_id, environment)
        ).fetchone()

    if not row:
        raise HTTPException(
            status_code=404,
            detail=f"No {provider_id} credentials configured "
                   f"for environment {environment}"
        )

    return decrypt_credentials(row["encrypted_credentials"])

This decrypted credential dictionary is then passed to the provider adapter's constructor:

python# In the routing/payment flow
credentials = await get_decrypted_credentials(
    app_id, "stripe", "production"
)

provider = StripeProvider(credentials)
# The provider reads credentials["api_key"] and
# credentials["webhook_secret"] internally

Credential Management API

Merchants manage their credentials through the dashboard, which calls these API endpoints:

Adding Credentials

python@router.post("/v1/apps/{app_id}/providers")
async def add_provider_credentials(
    app_id: str,
    data: ProviderCredentialCreate,
    auth: dict = Depends(get_auth_context),
):
    """Add payment provider credentials to an app."""
    # Verify app ownership
    verify_app_ownership(app_id, auth["user_id"])

    # Encrypt credentials
    encrypted = encrypt_credentials(data.credentials)

    cred_id = f"pcred_{generate_id()}"

    with get_db() as conn:
        # Check for existing credentials
        existing = conn.execute(
            """
            SELECT id FROM provider_credentials
            WHERE app_id = ? AND provider_id = ? AND environment = ?
            """,
            (app_id, data.provider_id, data.environment)
        ).fetchone()

        if existing:
            # Update existing credentials
            conn.execute(
                """
                UPDATE provider_credentials
                SET encrypted_credentials = ?,
                    is_active = 1,
                    updated_at = ?
                WHERE id = ?
                """,
                (encrypted, datetime.utcnow().isoformat(),
                 existing["id"])
            )
            return {"id": existing["id"], "updated": True}
        else:
            # Insert new credentials
            conn.execute(
                """
                INSERT INTO provider_credentials
                    (id, app_id, provider_id, environment,
                     encrypted_credentials, is_active, created_at)
                VALUES (?, ?, ?, ?, ?, 1, ?)
                """,
                (cred_id, app_id, data.provider_id,
                 data.environment, encrypted,
                 datetime.utcnow().isoformat())
            )
            return {"id": cred_id, "created": True}

Listing Credentials

When listing credentials, the encrypted data is never returned. Only metadata is shown:

python@router.get("/v1/apps/{app_id}/providers")
async def list_provider_credentials(
    app_id: str,
    auth: dict = Depends(get_auth_context),
):
    """List configured providers for an app (without credentials)."""
    verify_app_ownership(app_id, auth["user_id"])

    with get_db() as conn:
        creds = conn.execute(
            """
            SELECT id, provider_id, environment,
                   is_active, created_at, updated_at
            FROM provider_credentials
            WHERE app_id = ?
            ORDER BY provider_id, environment
            """,
            (app_id,)
        ).fetchall()

    return {
        "data": [
            {
                "id": c["id"],
                "provider_id": c["provider_id"],
                "environment": c["environment"],
                "is_active": bool(c["is_active"]),
                "created_at": c["created_at"],
                "updated_at": c["updated_at"],
                # Note: encrypted_credentials is NEVER returned
            }
            for c in creds
        ]
    }

Removing Credentials

python@router.delete("/v1/apps/{app_id}/providers/{credential_id}")
async def remove_provider_credentials(
    app_id: str,
    credential_id: str,
    auth: dict = Depends(get_auth_context),
):
    """Remove provider credentials from an app."""
    verify_app_ownership(app_id, auth["user_id"])

    with get_db() as conn:
        conn.execute(
            "DELETE FROM provider_credentials WHERE id = ? AND app_id = ?",
            (credential_id, app_id)
        )

    # Invalidate cached provider instance
    provider_registry.clear_cache_for_app(app_id)

    return {"deleted": True}

The Security Audit Findings

A security review conducted after the initial build (documented in weaknesses-and-improvements.md) identified several issues with the encryption implementation:

Finding 1: Fixed Encryption Salt

The original implementation used a hardcoded salt:

pythonsalt=b"0fee.dev.salt.v1"  # Fixed salt -- identified as weak

Impact: If two different 0fee.dev deployments use the same ENCRYPTION_KEY, their derived Fernet keys would be identical. In practice, since 0fee.dev has a single deployment, this was low-risk but still a deviation from best practice.

Recommended fix:

pythonimport os

def encrypt_credentials(credentials: dict) -> str:
    """Encrypt with random salt, stored alongside ciphertext."""
    salt = os.urandom(16)  # Random salt per encryption
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=100_000,
    )
    key = base64.urlsafe_b64encode(kdf.derive(
        settings.ENCRYPTION_KEY.encode()
    ))
    f = Fernet(key)
    encrypted = f.encrypt(
        json.dumps(credentials).encode()
    )

    # Store salt + encrypted data together
    combined = base64.urlsafe_b64encode(
        salt + base64.urlsafe_b64decode(encrypted)
    )
    return combined.decode()

Finding 2: API Key Visibility in Logs

Provider API keys could appear in error logs, exception tracebacks, and debug output. The recommendation was to implement log masking:

pythondef mask_sensitive(value: str) -> str:
    """Mask sensitive values for logging."""
    if len(value) <= 8:
        return "****"
    return value[:4] + "****" + value[-4:]

# Usage in logging
logger.info(
    "Initiating payment with %s (key: %s)",
    provider_id,
    mask_sensitive(credentials.get("api_key", ""))
)
# Output: "Initiating payment with stripe (key: sk_l****def0)"

Finding 3: No Credential Validation

When a merchant adds credentials, there was no verification that the credentials actually work. A typo in a Stripe API key would only be discovered when a real payment failed.

Recommended fix: Add a validation step using the provider's validate_credentials method:

python@router.post("/v1/apps/{app_id}/providers/validate")
async def validate_credentials(
    app_id: str,
    data: ProviderCredentialCreate,
    auth: dict = Depends(get_auth_context),
):
    """Test provider credentials before saving."""
    provider_class = provider_registry.get_class(data.provider_id)

    if not provider_class:
        raise HTTPException(404, f"Unknown provider: {data.provider_id}")

    provider = provider_class(data.credentials)

    try:
        is_valid = await provider.validate_credentials()
        return {"valid": is_valid}
    except NotImplementedError:
        return {
            "valid": None,
            "message": f"{data.provider_id} does not support "
                       f"credential validation"
        }

Security Improvements Roadmap

The audit identified a progression of security improvements:

PriorityImprovementStatus
HighRandom salt per encryptionPlanned
HighAPI key masking in logsPlanned
HighCredential validation endpointPlanned
MediumKey rotation mechanismPlanned
MediumAudit log for credential accessPlanned
LowHardware security module (HSM)Future
LowPer-tenant encryption keysFuture

Key Rotation

The most significant improvement on the roadmap is key rotation -- the ability to change the master ENCRYPTION_KEY without downtime. The approach:

  1. Support multiple active encryption keys (current + previous).
  2. New encryptions use the current key.
  3. Decryptions try the current key first, then fall back to previous keys.
  4. A background job re-encrypts all credentials with the current key.
python# Future: key rotation support
ENCRYPTION_KEYS = [
    settings.ENCRYPTION_KEY,          # Current
    settings.PREVIOUS_ENCRYPTION_KEY,  # Previous (for migration)
]

def decrypt_credentials(encrypted_str: str) -> dict:
    """Try current key first, fall back to previous keys."""
    for key_str in ENCRYPTION_KEYS:
        if not key_str:
            continue
        try:
            key = _derive_key(key_str)
            f = Fernet(key)
            decrypted = f.decrypt(encrypted_str.encode())
            return json.loads(decrypted)
        except Exception:
            continue

    raise ValueError("Unable to decrypt credentials with any known key")

The Trust Foundation

Credential management is the trust foundation of a payment orchestrator. Merchants trust 0fee.dev with their most sensitive data -- the keys that control their money flow. The encryption layer must be robust enough to justify that trust, transparent enough to be auditable, and practical enough to not impede the developer experience. The initial implementation established the baseline; the security audit identified the path to hardened production readiness.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles