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.

Fig. — What an attacker achieves from their own machine with each leaked key, on the simulated exchange
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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 exchangeCredentialWhat 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
AlpacaKey and secret, different for paper and liveRead and trade on the account; according to Alpaca, Trading API keys cannot order bank transfers
OANDAPersonal tokenAccess 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 BrokersUsername, password and second factor in TWS or IB GatewayThe API session is opened by the local program; the API has a read-only mode and a list of trusted IPs
MetaTrader 5Account number, password and serverThe 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.

Manager or fileBot processSignature and headerBroker or exchangeLogsChild processesRepo and notebooks
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.

IN [01]python
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)}")
OUT [01]Toy keys and their fingerprint
ALPACA_KEY_ID   40 characters  fingerprint eed46cb8
ALPACA_SECRET   40 characters  fingerprint e9633bee
BINANCE_SECRET  40 characters  fingerprint 45d99b76
OANDA_TOKEN     40 characters  fingerprint fcdf7418

Where 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_DIRECTORY and 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>/environ contains the initial environment of a process; it can be read by whoever passes the ptrace access 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:

IN [02]python
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))
OUT [02]Reading a credential: credential file, environment or error
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 e9633bee

Three 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:

IN [03]ini
# /etc/systemd/system/bot.service (excerpt)
[Service]
User=bot
LoadCredential=ALPACA_SECRET:/etc/bot/credenciales/alpaca_secret
ExecStart=/opt/bot/.venv/bin/python -m bot

System 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:

IN [04]llavero_y_dotenv.pypython
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 wins

In 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.:

IN [05]gestores_en_la_nube.pypython
# 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"]
OptionEncryption at restRead auditingRotationCost and own risk
Environment variable or .envNoNoManualInherited and leaked easily
Credential file (systemd, Docker)Optional (LoadCredentialEncrypted= in systemd)NoManualDepends on the file's permissions
System keychain (keyring)Yes, with the user's sessionNoManualAwkward on headless servers
Cloud manager or VaultYesYesYes, programmableAnother 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.

IN [06]python
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}")
OUT [06]A Secreto type
<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 serialized

The 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:

IN [07]python
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&timestamp=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")
OUT [07]A formatter that redacts secrets
DEBUG POST /api/v3/order?symbol=XSNUSDT&timestamp=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

IN [08]python
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())
OUT [08]The parent's environment reaches the child
child with the inherited environment: True
child with a filtered environment:    False

The 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, H=−∑ipilog⁡2piH = -\sum_i p_i \log_2 p_i, with pip_i 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:

IN [09]python
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()))
OUT [09]A repository with leaks
['.env', '.gitignore', 'README.md', 'analisis.ipynb', 'config.py', 'test_ordenes.py']
IN [10]python
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))
OUT [10]An entropy scanner
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: 6

The 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.

Fig. — Per-character entropy of the long strings in the toy repository and the scanner threshold

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.

IN [11]yaml
# .pre-commit-config.yaml: Gitleaks before each commit
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks

Least 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 exchangeKey permissionsIP restrictionOther
BinanceRead, spot and margin trading, withdrawal (and others)Yes; mandatory to enable withdrawalAccording to its FAQ, an HMAC key without IP restriction can only have read permission
BybitRead-only or read-write, per product; withdrawal only on the main accountYesA key with no IP bound stops working after 90 days
KrakenQuery funds, orders and trades; create, modify and cancel orders; deposit; withdrawal; websocketYesConfigurable key expiry and nonce window
Coinbase (CDP)view, trade and transferIP allowlist recommended, not mandatoryPortfolio the key is limited to
AlpacaNo per-key permissions; separate paper and live keysNot documentedTrading API keys cannot order bank transfers
Interactive BrokersAPI read-only mode (enabled by default)Trusted IPs in TWS or IB GatewayOption to accept local connections only
OANDAToken without per-function permissionsNot documentedRevoke 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):

IN [12]python
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
OUT [12]An exchange with permissions and an IP allowlist
                        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      200

From 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.

new keyreplicas read itold key in useno useCreatePublishCheckRevoke
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 --> [*]
IN [13]python
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)
OUT [13]Rotation: revoke at once or with overlap
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 24

Revoking 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.

Fig. — Rotation revoking the old key in the same minute the new one is published: each replica's request per minute

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:

IN [14]python
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)}")
OUT [14]Key inventory: what needs rotating
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 now

The 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:

Revoke the keyReview activityNew keyFind the pathClose the path
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]
  1. 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.
  2. 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.
  3. 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.
  4. Find the path through which it leaked: the commit, the log, the screenshot, the dependency.
  5. Close it: the scanner before the commit, redaction in the logs, the credential file instead of the environment.

Exercises

  1. Extend Tachador so that it also redacts Kraken's API-Sign header (base64) and any JWT (three base64url parts separated by dots, the first starting with eyJ), even when it is not preceded by Bearer.
Solution
IN [15]python
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)
OUT [15]Redacting Kraken signatures and stray JWTs
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.

  1. 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
IN [16]python
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]}")
OUT [16]Scanner with allowed patterns and PEM keys
analisis.ipynb:1 -> H = 4.17
config.py:3 -> H = 4.01
clave_ed25519.pem:1 -> PEM private key

The 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.

  1. 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

  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. ↩

  2. Linux man-pages, proc_pid_environ(5), accessed on October 22, 2026. ↩

  3. OWASP, Secrets Management Cheat Sheet and Logging Cheat Sheet (“Data to exclude” section), accessed on October 22, 2026. ↩ ↩2 ↩3

  4. keyring on PyPI and its README (section on headless Linux); python-dotenv on PyPI, accessed on October 22, 2026. ↩

  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. ↩

  6. Gitleaks, detect-secrets, GitHub, supported secret scanning patterns and push protection, accessed on October 22, 2026. ↩

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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. A Secreto type 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.

Resources

Data