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_keyEach credential set must be:
- Encrypted at rest -- plaintext credentials never stored in the database.
- Decryptable at runtime -- the system must decrypt credentials when initiating a payment.
- Isolated per app -- one merchant's credentials must never be accessible to another.
- 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:
| Feature | Fernet | Raw AES |
|---|---|---|
| Authenticated encryption | Built-in (HMAC-SHA256) | Manual (separate MAC needed) |
| IV/nonce management | Automatic | Manual |
| Timestamp | Included (for token expiry) | Not included |
| Implementation risk | Low (high-level API) | High (many pitfalls) |
| Performance | Adequate for credential encryption | Faster 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:
- Key stretching. If the master key has low entropy (e.g., a short passphrase), PBKDF2 makes brute-force attacks computationally expensive.
- 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:
| Provider | Credentials |
|---|---|
| Stripe | api_key, webhook_secret |
| PayPal | client_id, client_secret |
| Hub2 | api_key, subscription_key |
| PawaPay | api_key |
| BUI | api_key, webhook_secret |
| PaiementPro | merchant_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"] internallyCredential 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 weakImpact: 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:
| Priority | Improvement | Status |
|---|---|---|
| High | Random salt per encryption | Planned |
| High | API key masking in logs | Planned |
| High | Credential validation endpoint | Planned |
| Medium | Key rotation mechanism | Planned |
| Medium | Audit log for credential access | Planned |
| Low | Hardware security module (HSM) | Future |
| Low | Per-tenant encryption keys | Future |
Key Rotation
The most significant improvement on the roadmap is key rotation -- the ability to change the master ENCRYPTION_KEY without downtime. The approach:
- Support multiple active encryption keys (current + previous).
- New encryptions use the current key.
- Decryptions try the current key first, then fall back to previous keys.
- 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.