---
title: "4.4.5 Secure API credentials and least privilege"
description: "Where to store a trading system's keys, how to keep them out of logs and repos, no-withdrawal permissions, IP allowlists and rotation without downtime."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege.png"
---

# 4.4.5 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.4 · Brokers and APIs
- Duration: 60 min
- Prerequisites: [0.2.4 · Basic terminal, paths, environment variables and .env files](https://pythonparatrading.com/en/python-trading-course/level-0/terminal-paths-environment-variables/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/), [3.7.4 · ccxt for trading on multiple exchanges](https://pythonparatrading.com/en/python-trading-course/level-3/ccxt-multi-exchange-trading/), [4.4.3 · Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20](https://pythonparatrading.com/en/python-trading-course/level-4/alpaca-tradier-metatrader-5-and-oanda/), [4.4.4 · Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase](https://pythonparatrading.com/en/python-trading-course/level-4/crypto-exchange-apis-ccxt-and-native/)

## By the end of this lesson

- Choose where each credential lives (environment, credential file, system keychain, secret manager) according to its risks and its cost.
- Keep a secret from leaking through `print`, logs, exceptions, child processes or the repository, and detect the ones that already leaked.
- Design the permissions of each key per process: read, trade without withdrawal and IP restriction, and know what each broker or exchange offers.
- Rotate a key without interrupting the system and have a procedure for when one leaks.

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

```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)}")
```
```output
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>`[^1].
- **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
  root[^2].

The OWASP secrets management guide sums up the recommendation: environment variables are “not recommended unless
other methods are not possible”[^3]. A function that reads a credential in that order of preference, and that refuses
to read a file other users can read:

```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))
```
```output
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:

```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 advantage[^4]. `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:

```python skip title="llavero_y_dotenv.py"
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 them[^5]:

```python skip title="gestores_en_la_nube.py"
# 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.

```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}")
```
```output
<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 others[^3]. 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:

```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")
```
```output
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

```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())
```
```output
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 = -\sum_i p_i \log_2 p_i$, with $p_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:

```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()))
```
```output
['.env', '.gitignore', 'README.md', 'analisis.ipynb', 'config.py', 'test_ordenes.py']
```

```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))
```
```output
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.* Horizontal bars with the entropy of six strings. Above the 3.5 bits-per-character threshold (highlighted) are the notebook token (4.17), the key in config.py (4.01) and an order UUID (3.68), a false positive. Below it, a README identifier (3.29), a strategy name (3.17) and a filler of x's (0).

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 findings[^6].
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.

```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 do[^7]:

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

```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
```
```output
                        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 mistakes[^3]. 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
stateDiagram-v2
  [*] --> Create
  Create --> Publish: new key
  Publish --> Check: replicas read it
  Check --> Check: old key in use
  Check --> Revoke: no use
  Revoke --> [*]
```

```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)
```
```output
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.* Three rows, one per replica with 0, 2 and 5 minutes of cache, and one mark per minute from 14 to 31. Up to minute 20 all use the old key; the replica without cache switches to the new one at 20. Highlighted, the 7 failed requests: the 2-minute replica's at minutes 20 and 21 and the 5-minute replica's from 20 to 24. With overlap, none fails.

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

```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)}")
```
```output
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:

```mermaid
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.**

```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)
```
```output
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.

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

```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]}")
```
```output
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.

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

[^1]: [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html) (credentials section, `LoadCredential=` and `$CREDENTIALS_DIRECTORY`), [System and Service Credentials](https://systemd.io/CREDENTIALS/) and Docker, [Manage secrets securely in Docker Compose](https://docs.docker.com/compose/how-tos/use-secrets/), accessed on October 22, 2026.
[^2]: Linux man-pages, [proc_pid_environ(5)](https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html), accessed on October 22, 2026.
[^3]: OWASP, [Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) (“Data to exclude” section), accessed on October 22, 2026.
[^4]: [keyring on PyPI](https://pypi.org/project/keyring/) and its [README](https://github.com/jaraco/keyring) (section on headless Linux); [python-dotenv on PyPI](https://pypi.org/project/python-dotenv/), accessed on October 22, 2026.
[^5]: [AWS Secrets Manager with Python](https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets-python-sdk.html), [Google Cloud Secret Manager: access a secret version](https://cloud.google.com/secret-manager/docs/access-secret-version), [Azure Key Vault Secrets for Python](https://learn.microsoft.com/en-us/python/api/overview/azure/keyvault-secrets-readme) and [hvac: KV v2](https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v2.html), accessed on October 22, 2026.
[^6]: [Gitleaks](https://github.com/gitleaks/gitleaks), [detect-secrets](https://github.com/Yelp/detect-secrets), GitHub, [supported secret scanning patterns](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns) and [push protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection), accessed on October 22, 2026.
[^7]: Binance, [How to Create API Keys on Binance](https://www.binance.com/en/support/faq/how-to-create-api-keys-on-binance-360002502072) and [key permissions](https://developers.binance.com/docs/wallet/account/api-key-permission); Bybit, [create a sub-account key](https://bybit-exchange.github.io/docs/v5/user/create-subuid-apikey) and [key information](https://bybit-exchange.github.io/docs/v5/user/apikey-info); Kraken, [API keys](https://docs.kraken.com/exchange/guides/rest/api-keys) and [permissions](https://support.kraken.com/gb/articles/360000919966-how-to-create-an-api-key); Coinbase, [authentication](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication) and [permissions per endpoint](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/rest-scopes); Alpaca, [Authentication](https://docs.alpaca.markets/docs/authentication) and [API key security best practices](https://alpaca.markets/learn/api-key-security-best-practices-for-alpaca-builders); Interactive Brokers, [initial setup](https://interactivebrokers.github.io/tws-api/initial_setup.html) (“Read Only” enabled by default), [API settings](https://www.interactivebrokers.com/docs/tws-api/protobuf/api-settings-config) and [remote connections](https://www.interactivebrokers.com/docs/tws-api/doc/connectivity/remote-tws-api-connections-with-trader-workstation); OANDA, [Authentication](https://developer.oanda.com/rest-live-v20/authentication/), accessed on October 22, 2026.

## 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?

   - A. Nothing: the environment is private to each process
   - B. Only the variables that start with `PATH`
   - C. The parent's whole environment, key included, unless it is given a filtered environment with `env=`
   - D. Only if the child is a Python program

   *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?

   - A. Read and trade, no withdrawal, restricted to the server's IP
   - B. All permissions, to avoid problems
   - C. Withdrawal only
   - D. Read and withdrawal, no trading

   *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?

   - A. Because the new key is badly generated
   - B. Because two replicas of the system take 2 and 5 minutes to read the new key and keep using the revoked one
   - C. Because the exchange rate-limits requests
   - D. By chance

   *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?

   - A. Delete the commit and force push
   - B. Rename the environment variable
   - C. Wait and see whether anyone uses it
   - D. Revoke the key at the broker or exchange; then review the activity, create another one and clean the repository

   *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-…`?

   - 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
   - B. It ignores it because it is short
   - C. It deletes it from the file
   - D. It encrypts it

   *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

### Research

- [Finite-State Machines for Quantitative Trading Execution](https://pythonparatrading.com/en/finite-state-machines-trading-python/) · Research-0029 · 47 min

### Documentation

- [OWASP: Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) · cheatsheetseries.owasp.org
- [OWASP: Logging Cheat Sheet (data that must not be logged)](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) · cheatsheetseries.owasp.org
- [systemd.exec: credentials (LoadCredential=, $CREDENTIALS_DIRECTORY)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html) · freedesktop.org
- [Kraken: API keys, permissions and best practices](https://docs.kraken.com/exchange/guides/rest/api-keys) · docs.kraken.com
- [Docker: secrets in Compose (/run/secrets)](https://docs.docker.com/compose/how-tos/use-secrets/) · docs.docker.com

### Data

- [Level 4 data: sample broker messages (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Download · brokers4_ejemplos.json

Previous lesson: [4.4.4 · Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase](https://pythonparatrading.com/en/python-trading-course/level-4/crypto-exchange-apis-ccxt-and-native/)

Next lesson: [4.5.1 · Live system architecture: from data to reconciliation](https://pythonparatrading.com/en/python-trading-course/level-4/live-trading-system-architecture/)
