Lesson 4.4.5 · 60 min
Secure credentials: environment variables, secret managers and least privilege
An API key with withdrawal permission, no IP restriction and written in a file in the repository is an open account. This lesson follows the path of a credential, from where it is stored to how it is revoked, with toy keys that cannot be mistaken for real ones: reading from credential files, objects that refuse to be printed, redacted logs, a secret scanner, a simulated exchange with permissions and an IP allowlist, and a rotation without failed requests.
- Module
- 4.4 · Brokers and APIs
- Duration
- 60 min
- Prerequisites
- 0.2.4 · Basic terminal, paths, environment variables and .env files, 0.4.5 · Professional logging and testing with pytest, 3.7.4 · ccxt for trading on multiple exchanges, 4.4.3 · Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20, 4.4.4 · Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
What is protected and from whom
Lessons 4.4.3 and 4.4.4 used fake keys against simulators. In real trading, each broker issues a different credential and each one opens a different door:
| Broker or exchange | Credential | What it allows if it leaks |
|---|---|---|
| Crypto exchanges (Binance, Bybit, Kraken, Coinbase) | Key and secret (or Ed25519, RSA or ECDSA private key) | Whatever its permissions say: read, trade, withdraw |
| Alpaca | Key and secret, different for paper and live | Read and trade on the account; according to Alpaca, Trading API keys cannot order bank transfers |
| OANDA | Personal token | Access to all the sub-accounts of the account; OANDA does not store it, so if it is lost it is revoked and another one is generated |
| Interactive Brokers | Username, password and second factor in TWS or IB Gateway | The API session is opened by the local program; the API has a read-only mode and a list of trusted IPs |
| MetaTrader 5 | Account number, password and server | The whole account from any terminal |
The sources for each row are in the permissions section. What has to be protected is not just the key: also a JWT token while it is valid (lesson 4.4.4), a signed URL while it is inside its time window and a terminal's session file.
From whom it is protected, in order of real frequency: from yourself (a print, a screenshot, a notebook saved with
its output, a commit), from the logs (logs that are copied to other systems or to an external service), from the
machine itself (other processes and users, backups) and, last, from an attacker who gets into the server or into a
dependency. The first three need no one malicious for the key to end up where it should not.
Mermaid source
flowchart TB G[Manager or file] --> P[Bot process] P --> F[Signature and header] F --> X[Broker or exchange] P -.-> L[Logs] P -.-> H[Child processes] P -.-> R[Repo and notebooks]
The solid arrows are the path the credential should follow; the dashed ones, the leaks this lesson closes one by one.
Toy keys
The cells use keys that cannot be mistaken for real ones: they start with FALSA_ and the rest comes from the SHA-256
of the name. Not even those are printed: to identify a key without revealing it, its fingerprint is enough, the
first characters of the SHA-256 of its value, which is what can be written in a log or a report.
import hashlib
def falsa(nombre, largo=40):
"""Toy key: FALSA_ prefix and deterministic padding."""
return "FALSA_" + hashlib.sha256(nombre.encode()).hexdigest()[
: largo - 6]
def huella(valor):
"""Identifies a key without revealing it: 8 hex chars of its SHA-256."""
return hashlib.sha256(valor.encode()).hexdigest()[:8]
CLAVES = {n: falsa(n) for n in ["ALPACA_KEY_ID", "ALPACA_SECRET",
"BINANCE_SECRET", "OANDA_TOKEN"]}
for n, v in CLAVES.items():
print(f"{n:<15} {len(v)} characters fingerprint {huella(v)}")ALPACA_KEY_ID 40 characters fingerprint eed46cb8
ALPACA_SECRET 40 characters fingerprint e9633bee
BINANCE_SECRET 40 characters fingerprint 45d99b76
OANDA_TOKEN 40 characters fingerprint fcdf7418Where each credential lives
Lesson 0.2.4 showed how to read environment variables and .env files and how to display the configuration without
the secrets. That is the minimum, and on a personal computer it is usually enough. On a server that trades on its
own, you have to go further, because environment variables have two problems that their own documentation
acknowledges:
- They are inherited and exposed. The systemd page on service execution says that environment variables are
“not suitable for passing secrets” to a service: they are exposed to unprivileged clients over D-Bus and propagate
to the whole process tree. It recommends its credentials (
LoadCredential=), which are delivered as files in a directory whose path is in$CREDENTIALS_DIRECTORYand which do not propagate to children. The Docker Compose secrets guide says the same in other words (“environment variables are often available to all processes” and can end up in logs) and mounts each secret as a file in/run/secrets/<name>1Note 1 · systemd.exec (credentials section,LoadCredential=and$CREDENTIALS_DIRECTORY), System and Service Credentials and Docker, Manage secrets securely in Docker Compose, accessed on October 22, 2026.. - They can be read from outside the process. On Linux,
/proc/<pid>/environcontains the initial environment of a process; it can be read by whoever passes theptraceaccess check, which in practice is the same user or root2Note 2 · Linux man-pages, proc_pid_environ(5), accessed on October 22, 2026..
The OWASP secrets management guide sums up the recommendation: environment variables are “not recommended unless other methods are not possible”3Note 3 · OWASP, Secrets Management Cheat Sheet and Logging Cheat Sheet (“Data to exclude” section), accessed on October 22, 2026.. A function that reads a credential in that order of preference, and that refuses to read a file other users can read:
import os
import stat
import tempfile
from pathlib import Path
class CredencialAusente(LookupError):
pass
def leer_credencial(nombre):
"""File in $CREDENTIALS_DIRECTORY (systemd) or /run/secrets
(Docker); otherwise, environment variable; otherwise, error."""
for d in filter(None, [os.environ.get("CREDENTIALS_DIRECTORY"),
"/run/secrets"]):
p = Path(d) / nombre
if p.is_file():
modo = p.stat().st_mode
if modo & (stat.S_IRWXG | stat.S_IRWXO):
raise PermissionError(
f"{nombre}: permissions {stat.filemode(modo)}; only "
"its owner should be able to read it (chmod 600)")
return p.read_text().strip(), "file"
if nombre in os.environ:
return os.environ[nombre], "env"
raise CredencialAusente(f"{nombre} is missing; no default value")
credenciales = Path(tempfile.mkdtemp(prefix="cred-"))
os.environ["CREDENTIALS_DIRECTORY"] = str(credenciales)
fichero = credenciales / "ALPACA_SECRET"
fichero.write_text(CLAVES["ALPACA_SECRET"] + "\n")
fichero.chmod(0o644) # readable by everyone
os.environ["ALPACA_KEY_ID"] = CLAVES["ALPACA_KEY_ID"]
for n in ["ALPACA_KEY_ID", "ALPACA_SECRET", "OANDA_TOKEN"]:
try:
v, origen = leer_credencial(n)
print(f"{n:<14} {origen:<8} fingerprint {huella(v)}")
except (PermissionError, CredencialAusente) as e:
print(f"{n:<14} {type(e).__name__}: {e}")
fichero.chmod(0o600)
v, origen = leer_credencial("ALPACA_SECRET")
print("after chmod 600:", origen, huella(v))ALPACA_KEY_ID env fingerprint eed46cb8
ALPACA_SECRET PermissionError: ALPACA_SECRET: permissions -rw-r--r--; only its owner should be able to read it (chmod 600)
OANDA_TOKEN CredencialAusente: OANDA_TOKEN is missing; no default value
after chmod 600: file e9633beeThree deliberate behaviors. The Alpaca key comes from the environment because there is no file. The secret is in a
file everyone can read (-rw-r--r--) and the function refuses to use it until the permissions are owner-only: it is
the same check ssh does with private keys. And the OANDA token does not exist: the function raises an error instead
of returning a default value, because a default secret is a secret in the code.
With systemd, the service declares the credential and the program reads it from the directory, with no environment variables:
# /etc/systemd/system/bot.service (excerpt)
[Service]
User=bot
LoadCredential=ALPACA_SECRET:/etc/bot/credenciales/alpaca_secret
ExecStart=/opt/bot/.venv/bin/python -m botSystem keychains and secret managers
On a desktop computer, the operating system's keychain stores the credential encrypted with the user's session. The
keyring library (version 25.7.0, MIT license) uses the macOS Keychain, the Windows Credential Manager and Secret
Service or KWallet on Linux; on a headless server it needs to start a keyring service and unlock it, which takes away
much of the advantage4Note 4 · keyring on PyPI and its README (section on headless Linux); python-dotenv on PyPI, accessed on October 22, 2026.. python-dotenv (1.2.4, BSD-3-Clause) loads a .env into os.environ without overwriting
variables that already exist, the same precedence as the custom reader of lesson 0.2.4. Neither of the two is in the
course environment:
import keyring # uv add keyring
from dotenv import load_dotenv # uv add python-dotenv
keyring.set_password("bot-trading", "binance", "<pasted once>")
secreto = keyring.get_password("bot-trading", "binance")
load_dotenv(".env", override=False) # the environment winsIn the cloud, each provider has its secret manager: it stores the value encrypted, logs who reads it, grants permissions by machine identity (no keys needed to read the keys) and can rotate the secret. The call to read one is similar in all of them5Note 5 · AWS Secrets Manager with Python, Google Cloud Secret Manager: access a secret version, Azure Key Vault Secrets for Python and hvac: KV v2, accessed on October 22, 2026.:
# AWS Secrets Manager (boto3)
import boto3
sm = boto3.client("secretsmanager")
secreto = sm.get_secret_value(SecretId="bot/binance")["SecretString"]
# Google Cloud Secret Manager
from google.cloud import secretmanager
cliente = secretmanager.SecretManagerServiceClient()
nombre = "projects/mi-proyecto/secrets/binance/versions/latest"
secreto = cliente.access_secret_version(
request={"name": nombre}).payload.data.decode("UTF-8")
# Azure Key Vault
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
kv = SecretClient(vault_url="https://mi-boveda.vault.azure.net/",
credential=DefaultAzureCredential())
secreto = kv.get_secret("binance").value
# HashiCorp Vault (hvac), KV secrets engine version 2
import hvac
secreto = hvac.Client().secrets.kv.v2.read_secret_version(
path="bot/binance")["data"]["data"]["secret"]| Option | Encryption at rest | Read auditing | Rotation | Cost and own risk |
|---|---|---|---|---|
Environment variable or .env | No | No | Manual | Inherited and leaked easily |
| Credential file (systemd, Docker) | Optional (LoadCredentialEncrypted= in systemd) | No | Manual | Depends on the file's permissions |
System keychain (keyring) | Yes, with the user's session | No | Manual | Awkward on headless servers |
| Cloud manager or Vault | Yes | Yes | Yes, programmable | Another dependency: if it does not respond, the bot does not start; cost per read |
The AWS documentation recommends caching the value that was read: a bot that requests the secret on every order multiplies the cost and adds a point of failure to each request. The cache, in turn, is what makes a rotation take time to reach all the replicas, as the rotation simulation will show.
Keeping it from leaking
A type that refuses to be printed
Most leaks are a value printed by accident: in a debugging print, in the repr of a configuration dictionary, in
the f-string of an error message. A custom type whose repr is the fingerprint cuts all those paths at once, and
forces you to write .revelar() in the only place where the value is needed: when signing.
import json
import pickle
class Secreto:
__slots__ = ("nombre", "_valor")
def __init__(self, nombre, valor):
self.nombre, self._valor = nombre, valor
def revelar(self):
return self._valor
def __repr__(self):
return f"<Secreto {self.nombre} {huella(self._valor)}>"
__str__ = __repr__
def __reduce__(self):
raise TypeError("a Secreto cannot be serialized")
s = Secreto("BINANCE_SECRET", CLAVES["BINANCE_SECRET"])
config = {"exchange": "binance", "secret": s}
print(s)
print(f"signing with {s}")
print(config)
for nombre, f in [("json", json.dumps), ("pickle", pickle.dumps)]:
try:
f(config)
except TypeError as e:
print(f"{nombre}: {e}")<Secreto BINANCE_SECRET 45d99b76>
signing with <Secreto BINANCE_SECRET 45d99b76>
{'exchange': 'binance', 'secret': <Secreto BINANCE_SECRET 45d99b76>}
json: Object of type Secreto is not JSON serializable
pickle: a Secreto cannot be serializedThe secret shows up as its fingerprint in print, in the f-string and inside the dictionary. json.dumps does not
know how to serialize it and pickle is forbidden from doing so: the configuration cannot be dumped to a file or a
cache with the secret inside by accident. It is not encryption: the value is still in the process memory and
revelar() returns it. It protects against mistakes, not against someone who is already inside the process.
Redacted logs
The OWASP logging guide lists what must not be logged as is: session identifiers, access tokens, passwords, connection strings and encryption keys, among others3. A trading system logs HTTP requests to be able to debug them, and a signed request carries the key in a header and the signature in the URL. The signature is not the key, but while it is inside its time window it can be used to replay the request.
A logging.Filter can modify the record, but it does not see the text of an exception traceback, which is added at
formatting time. That is why redaction is done in a Formatter, on the final text: known values are replaced by
their fingerprint and patterns (signatures, key headers, bearer tokens), by a marker. So that the lesson's output does
not depend on file paths, the traceback is reduced to its last line:
import logging
import re
import sys
class Tachador(logging.Formatter):
PATRONES = [
(re.compile(r"(signature=)[0-9a-fA-F]+"), r"\1[REDACTED]"),
(re.compile(r"(Bearer )[\w.~+/=-]+"), r"\1[REDACTED]"),
(re.compile(r"('X-MBX-APIKEY': ')[^']+"), r"\1[REDACTED]"),
]
def __init__(self, fmt, secretos):
super().__init__(fmt)
self.secretos = [x.revelar() for x in secretos]
def formatException(self, ei):
return " " + super().formatException(ei).splitlines()[-1]
def format(self, record):
texto = super().format(record)
for v in self.secretos:
texto = texto.replace(v, f"[REDACTED {huella(v)}]")
for patron, cambio in self.PATRONES:
texto = patron.sub(cambio, texto)
return texto
log = logging.getLogger("bot.http")
log.setLevel(logging.DEBUG)
log.propagate = False
salida = logging.StreamHandler(sys.stdout)
salida.setFormatter(Tachador("%(levelname)s %(message)s", [s]))
log.addHandler(salida)
firma = hashlib.sha256(b"any signature").hexdigest()
log.debug("POST /api/v3/order?symbol=XSNUSDT×tamp=1760449080805"
"&signature=%s headers %s", firma,
{"X-MBX-APIKEY": falsa("BINANCE_KEY")})
log.info("websocket: Authorization: Bearer %s", falsa("JWT", 60))
try:
raise ValueError(f"secret with an odd format: {s.revelar()}")
except ValueError:
log.exception("signing failed")DEBUG POST /api/v3/order?symbol=XSNUSDT×tamp=1760449080805&signature=[REDACTED] headers {'X-MBX-APIKEY': '[REDACTED]'}
INFO websocket: Authorization: Bearer [REDACTED]
ERROR signing failed
ValueError: secret with an odd format: [REDACTED 45d99b76]The URL keeps everything useful for debugging (path, symbol, time) and loses the signature; the header, the key; the token, its value. The last line is the case almost everyone misses: an exception message built with the value of the secret (here on purpose; in real life, a library that includes the request in its error). The formatter replaces it with the fingerprint. The patterns are a net for what is not known in advance; what is known (the values loaded at startup) is redacted by value, which does not depend on getting a regular expression right.
What a child process inherits
import subprocess
os.environ["PPT_FALSA_SECRET"] = falsa("PPT_FALSA_SECRET")
hijo = [sys.executable, "-c",
"import os; print('PPT_FALSA_SECRET' in os.environ)"]
r = subprocess.run(hijo, capture_output=True, text=True)
print("child with the inherited environment:", r.stdout.strip())
limpio = {k: v for k, v in os.environ.items()
if not k.startswith("PPT_") and "SECRET" not in k
and k not in ("ALPACA_KEY_ID", "CREDENTIALS_DIRECTORY")}
r = subprocess.run(hijo, capture_output=True, text=True, env=limpio)
print("child with a filtered environment: ", r.stdout.strip())child with the inherited environment: True
child with a filtered environment: FalseThe child sees the variable with the secret; with a filtered env=, it does not. A bot launches children more often
than it seems: a report in another process, a git call to record the version, a log compression tool. Each one
receives the keys if they live in the environment, and any of them can write them into a dump or an error message.
With credential files the problem does not exist: the child only reads what it is given.
Detecting leaks before the commit
If a key reaches the repository, deleting it in the next commit does not help: it is still in the history, in the clones and in the copies others made. It has to be detected before the commit. Real tools combine patterns from known providers with measures of how random a text looks; the minimal version of the second is the Shannon entropy per character, , with the frequency of each character. A random hexadecimal text approaches 4 bits per character; a word or a readable identifier stays below 3.5.
The cell creates a toy repository with the typical leaks (a key written in config.py, a header with a token saved in
a notebook's output, a .env that is in .gitignore) and a test file with an Alpaca order identifier:
repo = Path(tempfile.mkdtemp(prefix="repo-"))
(repo / ".gitignore").write_text(".env\n__pycache__/\n")
(repo / ".env").write_text(
f"BINANCE_SECRET={CLAVES['BINANCE_SECRET']}\n")
(repo / "config.py").write_text(
'SIMBOLO = "XSNUSDT"\n'
'ESTRATEGIA = "momentum_veinte_sesiones"\n'
f'API_SECRET = "{falsa("API_SECRET")}" # just for testing\n')
(repo / "analisis.ipynb").write_text(json.dumps({"cells": [{
"outputs": [{"text": [
f"Authorization: Bearer {falsa('OANDA_TOKEN', 48)}\n"]}]}]}))
(repo / "test_ordenes.py").write_text(
'RELLENO = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n'
'ID_ORDEN = "6f1c2a9e-4b7d-4c1a-9e55-000000039595"\n')
(repo / "README.md").write_text(
"Strategy rebalanceo_diario_al_cierre with ETFs.\n")
print(sorted(p.name for p in repo.iterdir()))['.env', '.gitignore', 'README.md', 'analisis.ipynb', 'config.py', 'test_ordenes.py']import collections
import fnmatch
import math
TOKEN = re.compile(r"[A-Za-z0-9_+/=-]{20,}")
def entropia(texto):
n = len(texto)
return -sum(c / n * math.log2(c / n)
for c in collections.Counter(texto).values())
def escanear(repo, umbral=3.5, permitidos=()):
ignorados = (repo / ".gitignore").read_text().split()
hallazgos, todos = [], []
for p in sorted(repo.rglob("*")):
if not p.is_file() or p.name == ".gitignore" or any(
fnmatch.fnmatch(p.name, g.rstrip("/"))
for g in ignorados):
continue
for i, linea in enumerate(p.read_text().splitlines(), 1):
for t in TOKEN.findall(linea):
h = entropia(t)
todos.append((p.name, len(t), round(h, 2)))
if h >= umbral and not any(
re.fullmatch(x, t) for x in permitidos):
hallazgos.append((f"{p.name}:{i}", len(t),
round(h, 2), huella(t)))
return hallazgos, todos
hallazgos, todos = escanear(repo)
for h in hallazgos:
print("possible secret in {} ({} characters, H = {}) fingerprint {}"
.format(*h))
print("long texts checked:", len(todos))possible secret in analisis.ipynb:1 (48 characters, H = 4.17) fingerprint 3ed2901d
possible secret in config.py:3 (40 characters, H = 4.01) fingerprint bc1df40e
possible secret in test_ordenes.py:2 (36 characters, H = 3.68) fingerprint 0d85cfe0
long texts checked: 6The scanner finds the key in config.py and the notebook's token, which is a particularly treacherous leak: the
notebook's code has no key, but its saved output does. It does not look at the .env, because it is in .gitignore
and will not be pushed. It also flags the Alpaca order identifier, a UUID: it has high entropy and is not a secret.
That false positive is excluded with an allowed pattern (exercise 2), not by raising the threshold for everything.
The padding of x's is not flagged: zero entropy.
For a real repository there are maintained tools that do this and more: Gitleaks (MIT license; version 8.30.1) works as a pre-commit hook or as a GitHub action and ships rules for many providers, among them Coinbase and Kraken tokens but not Binance, Bybit or Alpaca ones; detect-secrets (Apache-2.0) works with a baseline of accepted findings6Note 6 · Gitleaks, detect-secrets, GitHub, supported secret scanning patterns and push protection, accessed on October 22, 2026.. GitHub blocks by default pushes of the secrets it recognizes to public repositories (push protection), but its list of patterns does not include Binance, Bybit, Kraken, Coinbase, Alpaca or OANDA keys: for those keys, only the generic detection. The lesson's own scanner, or a custom rule in Gitleaks, covers the gap.
# .pre-commit-config.yaml: Gitleaks before each commit
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaksLeast privilege
Each broker or exchange offers different controls over what a credential can do7Note 7 · Binance, How to Create API Keys on Binance and key permissions; Bybit, create a sub-account key and key information; Kraken, API keys and permissions; Coinbase, authentication and permissions per endpoint; Alpaca, Authentication and API key security best practices; Interactive Brokers, initial setup (“Read Only” enabled by default), API settings and remote connections; OANDA, Authentication, accessed on October 22, 2026.:
| Broker or exchange | Key permissions | IP restriction | Other |
|---|---|---|---|
| Binance | Read, spot and margin trading, withdrawal (and others) | Yes; mandatory to enable withdrawal | According to its FAQ, an HMAC key without IP restriction can only have read permission |
| Bybit | Read-only or read-write, per product; withdrawal only on the main account | Yes | A key with no IP bound stops working after 90 days |
| Kraken | Query funds, orders and trades; create, modify and cancel orders; deposit; withdrawal; websocket | Yes | Configurable key expiry and nonce window |
| Coinbase (CDP) | view, trade and transfer | IP allowlist recommended, not mandatory | Portfolio the key is limited to |
| Alpaca | No per-key permissions; separate paper and live keys | Not documented | Trading API keys cannot order bank transfers |
| Interactive Brokers | API read-only mode (enabled by default) | Trusted IPs in TWS or IB Gateway | Option to accept local connections only |
| OANDA | Token without per-function permissions | Not documented | Revoke and generate from access management |
Kraken's key guide sums it up in two pieces of advice: one key per purpose and the IP allowlist, which “prevents almost all misuse of a key even if it leaks”; for algorithmic spot trading, querying funds, querying orders, and creating and canceling orders are enough, and a key that only sends orders does not need withdrawal.
Applied to a system with three processes (a data and balance collector, the order engine and a daily report), the design is three keys, none with withdrawal and all tied to the server's IP. Withdrawal, if needed, is done by a person from the website, with their second factor. The simulator in the next cell applies the two rules shared by all the exchanges that have them (the IP is checked before the permission) and tests what each key achieves from the server and from an attacker's machine. The IPs come from the ranges reserved for documentation (RFC 5737):
import pandas as pd
IP_SERVIDOR, IP_ATACANTE = "198.51.100.7", "203.0.113.50"
NECESITA = {"saldo": "lectura", "orden": "operar",
"retirada": "retirar"}
class ExchangeSim:
def __init__(self):
self.claves = {}
def alta(self, kid, permisos, ips=None):
self.claves[kid] = {"permisos": set(permisos), "ips": ips,
"activa": True}
def atender(self, kid, ip, accion):
c = self.claves.get(kid)
if c is None or not c["activa"]:
return "401 key"
if c["ips"] is not None and ip not in c["ips"]:
return "401 IP"
if NECESITA[accion] not in c["permisos"]:
return "403"
return "200"
ex = ExchangeSim()
ex.alta("datos", {"lectura"}, ips={IP_SERVIDOR})
ex.alta("motor", {"lectura", "operar"}, ips={IP_SERVIDOR})
ex.alta("motor sin IP", {"lectura", "operar"})
ex.alta("tutorial", {"lectura", "operar", "retirar"})
filas = {}
for kid in ex.claves:
for origen, ip in [("servidor", IP_SERVIDOR),
("atacante", IP_ATACANTE)]:
filas[(kid, origen)] = {a: ex.atender(kid, ip, a)
for a in NECESITA}
pd.DataFrame(filas).T saldo orden retirada
datos servidor 200 403 403
atacante 401 IP 401 IP 401 IP
motor servidor 200 200 403
atacante 401 IP 401 IP 401 IP
motor sin IP servidor 200 200 403
atacante 200 200 403
tutorial servidor 200 200 200
atacante 200 200 200From the server, each key does what it should and nothing more: the data key (datos) cannot trade (403) and none of
the system's keys can withdraw. From the attacker's machine, the keys with an IP allowlist are useless (401 for IP).
The engine key without IP (motor sin IP) is the middle case: the attacker cannot take the funds, but can trade with
them, and trading is enough to do damage (buying an illiquid asset at absurd prices against their own orders placed
in another account moves the balance without withdrawing it). The “tutorial” key, with all permissions and no IP, is
the whole account: it is the one created in five minutes to try an example and forgotten in a file. The header figure
summarizes the attacker's column (atacante).
The cost of these controls has to be taken into account in the design. The IP allowlist cuts the system off abruptly if the server's outbound IP changes (a provider that reassigns addresses, a dynamic IP, a network change): all requests return 401 until someone updates the list. A server with a static IP and an alert on 401 solve it. Separating keys per process multiplies the ones that have to be stored and rotated.
Rotating without interrupting the system
The OWASP guide asks for secrets to be rotated regularly, so that a stolen credential is useful for only a short time, and for it to be automated, because by hand it is easy to make mistakes3. The typical mistake is revoking the old key at the same moment the new one is created: the system rarely reads the key at the same instant everywhere. The simulation has three replicas of the bot that read the key from the manager with different caches (0, 2 and 5 minutes) and make one request per minute. At minute 20 the new key is created and published; in one plan, the old one is revoked in that same minute; in the other, at minute 30, when the exchange confirms that nobody has used it for five minutes.
Mermaid source
stateDiagram-v2 [*] --> Create Create --> Publish: new key Publish --> Check: replicas read it Check --> Check: old key in use Check --> Revoke: no use Revoke --> [*]
def rotar(plan, minutos=40, cache=(0, 2, 5)):
ex = ExchangeSim()
ex.alta("k1", {"lectura", "operar"})
publicadas = [("k1", -60)] # (key, minute published)
uso, registro = {}, []
for m in range(minutos):
for (t, accion, kid) in plan:
if t == m and accion == "crear":
ex.alta(kid, {"lectura", "operar"})
elif t == m and accion == "publicar":
publicadas.append((kid, m))
elif t == m and accion == "revocar":
ex.claves[kid]["activa"] = False
for r, retraso in enumerate(cache):
kid = [k for k, t in publicadas if t <= m - retraso][-1]
res = ex.atender(kid, IP_SERVIDOR, "orden")
uso[kid] = m
registro.append((m, r, kid, res))
return registro, uso
a_la_vez = [(20, "crear", "k2"), (20, "publicar", "k2"),
(20, "revocar", "k1")]
con_solape = [(20, "crear", "k2"), (20, "publicar", "k2"),
(30, "revocar", "k1")]
for nombre, plan in [("at once", a_la_vez),
("with overlap", con_solape)]:
registro, uso = rotar(plan)
fallos = [(m, r) for m, r, k, res in registro if res != "200"]
print(f"{nombre:<12} failed requests: {len(fallos)}"
f" last use of k1: minute {uso['k1']}")
if fallos:
print(" (minute, replica):", fallos)at once failed requests: 7 last use of k1: minute 24
(minute, replica): [(20, 1), (20, 2), (21, 1), (21, 2), (22, 2), (23, 2), (24, 2)]
with overlap failed requests: 0 last use of k1: minute 24Revoking at once, 7 requests fail: those of the replica with a 2-minute cache in minutes 20 and 21 and those of the 5-minute one from 20 to 24. With overlap none fails, and the figure that makes it safe to revoke is the last use of the old key (minute 24): many exchanges show in their dashboard when each key was last used, and the procedure consists of checking it before revoking. In a bot that trades, 7 failed requests can be orders that are not sent or cancellations that do not arrive: the cost of rotating badly is not an error in a log, it is a position left uncontrolled for five minutes.
An inventory with dates
Rotating regularly requires knowing which keys exist, where they are used and when they were created. Some expire on their own: Bybit invalidates keys with no IP bound after 90 days, and Kraken allows creating keys with an expiry date. A minimal inventory, with a 90-day policy and warnings 15 days ahead:
from datetime import date
HOY = date(2026, 10, 22)
inventario = pd.DataFrame([
("binance", "motor", date(2026, 9, 1), True, "lectura,operar"),
("binance", "datos", date(2026, 3, 2), True, "lectura"),
("bybit", "motor", date(2026, 7, 30), False, "lectura,operar"),
("kraken", "informe", date(2026, 8, 10), True, "lectura"),
("kraken", "pruebas", date(2025, 11, 5), False,
"lectura,operar,retirar"),
], columns=["exchange", "uso", "creada", "con_ip", "permisos"])
def revisar(fila, politica=90, aviso=15):
edad = (HOY - fila.creada).days
notas = []
if "retirar" in fila.permisos:
notas.append("remove withdrawal")
if not fila.con_ip:
notas.append("no IP")
if fila.exchange == "bybit" and edad >= 90 - aviso:
notas.append(f"Bybit invalidates it in {90 - edad} days")
if edad >= politica:
notas.append("rotate now")
elif edad >= politica - aviso:
notas.append(f"rotate in {politica - edad} days")
return "; ".join(notas) or "compliant"
for f in inventario.itertuples():
print(f"{f.exchange:<8} {f.uso:<8} {(HOY - f.creada).days:>4} days"
f" {revisar(f)}")binance motor 51 days compliant
binance datos 234 days rotate now
bybit motor 84 days no IP; Bybit invalidates it in 6 days; rotate in 6 days
kraken informe 73 days compliant
kraken pruebas 351 days remove withdrawal; no IP; rotate nowThe inventory stores no key: it stores where each one is, what it is for and when it was created. The Binance data
key is past the policy (234 days) even though it is properly restricted; the Bybit engine key, with no IP, expires in
6 days by the exchange's own rule, and the system would stop if nobody rotates it first. Kraken's test key (pruebas)
has everything it should not: almost a year old, withdrawal and no IP. It is the most common case in practice: not
the key of the production system, but the one created for an experiment that nobody deleted.
If a key leaks
The order matters, and the first step admits no delay:
Mermaid source
flowchart TB A[Revoke the key] --> B[Review activity] B --> C[New key] C --> D[Find the path] D --> E[Close the path]
- Revoke at the broker or exchange. Do not delete the commit, do not change the environment variable: revoke. While the key is valid, anyone who has it can use it, and a key in a public repository can be copied in seconds.
- Review the activity since the last time the key is known to have been safe: orders, trades, withdrawals, configuration changes, logins. The system's own log (lesson 4.5.5) and the exchange's have to match.
- Create a new key with least privilege and deploy it. If other credentials were in the same place (the same
.env, the same server), all of them are rotated. - Find the path through which it leaked: the commit, the log, the screenshot, the dependency.
- Close it: the scanner before the commit, redaction in the logs, the credential file instead of the environment.
Exercises
- Extend
Tachadorso that it also redacts Kraken'sAPI-Signheader (base64) and any JWT (three base64url parts separated by dots, the first starting witheyJ), even when it is not preceded byBearer.
Solution
class TachadorPlus(Tachador):
PATRONES = Tachador.PATRONES + [
(re.compile(r"('API-Sign': ')[A-Za-z0-9+/=]+"), r"\1[REDACTED]"),
(re.compile(r"eyJ[\w-]+\.[\w-]+\.[\w-]+"), "[JWT REDACTED]"),
]
log2 = logging.getLogger("bot.http2")
log2.setLevel(logging.DEBUG)
log2.propagate = False
h2 = logging.StreamHandler(sys.stdout)
h2.setFormatter(TachadorPlus("%(levelname)s %(message)s", [s]))
log2.addHandler(h2)
import base64
cab = {"API-Key": "FALSA_kraken",
"API-Sign": base64.b64encode(b"FALSA" * 13).decode()}
jwt = ".".join(["eyJhbGciOiJFUzI1NiJ9", "eyJzdWIiOiJmYWxzYSJ9",
"ZmFsc2EtZmlybWE"])
log2.debug("POST /0/private/AddOrder headers %s", cab)
log2.debug("user subscription with jwt=%s", jwt)DEBUG POST /0/private/AddOrder headers {'API-Key': 'FALSA_kraken', 'API-Sign': '[REDACTED]'}
DEBUG user subscription with jwt=[JWT REDACTED]The Kraken signature is redacted by its header pattern and the JWT by its shape, which is recognizable: a JWT's
header is a JSON that starts with {", and {" in base64url is always eyJ. It is a pattern with few false
positives, unlike entropy, and that is why scanning tools use it.
- Add to the scanner a list of allowed patterns so that it does not flag UUIDs, and a rule that flags any line with
the header of a PEM private key (
-----BEGIN … PRIVATE KEY-----), whatever its entropy.
Solution
UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
PEM = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
(repo / "clave_ed25519.pem").write_text(
"-----BEGIN PRIVATE KEY-----\nFALSA\n-----END PRIVATE KEY-----\n")
def escanear_v2(repo):
hallazgos, _ = escanear(repo, permitidos=[UUID])
for p in sorted(repo.glob("*.pem")) + sorted(repo.glob("*.py")):
for i, linea in enumerate(p.read_text().splitlines(), 1):
if PEM.search(linea):
hallazgos.append((f"{p.name}:{i}", "PEM private key"))
return hallazgos
for h in escanear_v2(repo):
print(h[0], "->", h[1] if len(h) == 2 else f"H = {h[2]}")analisis.ipynb:1 -> H = 4.17
config.py:3 -> H = 4.01
clave_ed25519.pem:1 -> PEM private keyThe UUID no longer shows up and the PEM key is detected by its header, even though its content (here, the word
FALSA) does not have high entropy. The Ed25519, RSA and ECDSA private keys that Binance and Coinbase ask for (lesson
4.4.4) are stored in PEM files: they are the most sensitive secret in the system and must be outside the repository
and with owner-only permissions, just like the credential file of the first section.
- Design the credentials for the level project: a multi-strategy portfolio in paper trading with Alpaca or IBKR for 4 to 8 weeks, on your own server. Which credentials there are, where they are stored, what permissions they have and how to go live without reusing anything from paper.
Solution
In Alpaca, two different key pairs by design (paper and live), and the documentation says they cannot be mixed.
During the project only the paper pair exists, in a service credential file (systemd's LoadCredential= or Docker's
/run/secrets) with 600 permissions, read with a function like leer_credencial and wrapped in Secreto from
startup. The mode (paper or live) is decided by a non-secret environment variable, checked against the URL as in
lesson 4.4.3. Alpaca has no documented per-key permissions or IP allowlist, so the protection lives on the server: a
dedicated user for the bot, the credential file, the scanner before each commit and redacted logs. In IBKR the
credential is the TWS or IB Gateway user, which lives in that program and not in the bot; the bot connects through
the local port, and read-only mode is configured in the data process, along with trusted IPs and local connections
only. To go live: new keys created that day (never the paper ones), an inventory with their date, an alert on 401
and an overlapping rotation planned at 90 days. No credential in the system has withdrawal permission.
Footnotes
-
systemd.exec (credentials section,
LoadCredential=and$CREDENTIALS_DIRECTORY), System and Service Credentials and Docker, Manage secrets securely in Docker Compose, accessed on October 22, 2026. ↩ -
Linux man-pages, proc_pid_environ(5), accessed on October 22, 2026. ↩
-
OWASP, Secrets Management Cheat Sheet and Logging Cheat Sheet (“Data to exclude” section), accessed on October 22, 2026. ↩ ↩2 ↩3
-
keyring on PyPI and its README (section on headless Linux); python-dotenv on PyPI, accessed on October 22, 2026. ↩
-
AWS Secrets Manager with Python, Google Cloud Secret Manager: access a secret version, Azure Key Vault Secrets for Python and hvac: KV v2, accessed on October 22, 2026. ↩
-
Gitleaks, detect-secrets, GitHub, supported secret scanning patterns and push protection, accessed on October 22, 2026. ↩
-
Binance, How to Create API Keys on Binance and key permissions; Bybit, create a sub-account key and key information; Kraken, API keys and permissions; Coinbase, authentication and permissions per endpoint; Alpaca, Authentication and API key security best practices; Interactive Brokers, initial setup (“Read Only” enabled by default), API settings and remote connections; OANDA, Authentication, accessed on October 22, 2026. ↩
Each answer is folded under its question
Self-assessment
A bot reads its key from an environment variable and launches a child process to generate a report. What does the child see?
- ANothing: the environment is private to each process
- BOnly the variables that start with
PATH - CThe parent's whole environment, key included, unless it is given a filtered environment with
env= - DOnly if the child is a Python program
Show answer
Answer C. The parent's whole environment, key included, unless it is given a filtered environment with
env=A child process inherits the parent's environment. In the lesson, the child sees the variable with the key; with a filtered
env, it does not. That is why systemd and Docker recommend credential files instead of variables for secrets.Which combination of permissions is right for the order engine of a system on an exchange?
- ARead and trade, no withdrawal, restricted to the server's IP
- BAll permissions, to avoid problems
- CWithdrawal only
- DRead and withdrawal, no trading
Show answer
Answer A. Read and trade, no withdrawal, restricted to the server's IP
The engine needs to see balances and orders and to send orders; never to withdraw funds. Restricted to the server's IP, a leaked key is useless from another machine. In the lesson's simulator, that leaked key achieves nothing from the attacker's IP.
In the rotation simulation, revoking the old key in the same minute the new one is published gives 7 failed requests. Why?
- ABecause the new key is badly generated
- BBecause two replicas of the system take 2 and 5 minutes to read the new key and keep using the revoked one
- CBecause the exchange rate-limits requests
- DBy chance
Show answer
Answer B. Because two replicas of the system take 2 and 5 minutes to read the new key and keep using the revoked one
The replicas cache the credential for a while. With overlap (both keys valid until it is confirmed that nobody uses the old one) no request fails.
A key has been pushed by mistake to a public repository. What comes first?
- ADelete the commit and force push
- BRename the environment variable
- CWait and see whether anyone uses it
- DRevoke the key at the broker or exchange; then review the activity, create another one and clean the repository
Show answer
Answer D. Revoke the key at the broker or exchange; then review the activity, create another one and clean the repository
Deleting the commit does not undo the exposure: the key could have been copied in seconds and it is still in clones and caches. The only measure that shuts the door is revoking it at the issuer.
What does the lesson's entropy scanner do with the Alpaca order identifier
6f1c2a9e-…?- AIt flags it as a possible secret: a UUID has high entropy, and it has to be excluded with a list of allowed patterns
- BIt ignores it because it is short
- CIt deletes it from the file
- DIt encrypts it
Show answer
Answer A. It flags it as a possible secret: a UUID has high entropy, and it has to be excluded with a list of allowed patterns
Entropy measures how random a text looks, not whether it is secret. Real scanners combine known patterns, entropy and exception lists; a false positive is excluded explicitly, not by lowering the threshold for everything.
Key points
- A secret has no default value: if it is missing, the program does not start. It is read from a credential file (systemd, Docker) or from the environment, with owner-only permissions, and it is never written in the code or in the repository.
- The most common leaks are not attacks: a
print, a log of a signed URL, an exception, a child process that inherits the environment, a notebook cell saved with its output. ASecretotype that does not print and a redacting formatter cut almost all of them. - Least privilege per process: read for data and reports, trade without withdrawal for the engine, restricted to the server's IP. No program in the system needs withdrawal.
- Rotating is creating the new key, deploying it, checking that nobody uses the old one and only then revoking it; if a key leaks, it is revoked first and investigated afterwards.
- Every control has a cost: the IP allowlist cuts the system off if the server's IP changes; rotation, if it is done without overlap; the secret manager is another dependency that can fail.