Security
How the MT5 credentials you give us are stored and used.
This page is about the MT5 credentials you hand us, since that is the part of the system a customer is entitled to ask hard questions about. It is written to be checkable against the code, and it says what we do not protect against as plainly as what we do.
What happens to a password
You send it once, to POST /v1/accounts, over TLS. From there:
- A random data key is generated for that one secret — 32 bytes, used once, for nothing else.
- The password is encrypted with it using AES-256-GCM, with
"<tenantId>:<accountId>"as additional authenticated data. - The data key is encrypted with a key-encrypting key that lives in the API process's configuration, never in the database.
- Only the sealed result is stored: ciphertext, wrapped data key, key version and the AAD. The account row holds nothing but a random reference.
- The data key is zeroed in memory on the way out, in a
finallyblock, so it does not survive an exception.
The reference is a random UUID, deliberately not derived from the account id. If it were, an attacker holding the accounts table would hold every reference and the indirection would buy nothing.
The AAD binding is what stops a sealed record being moved. Copy one account's row onto another account and decryption fails outright rather than returning the wrong password — the account id is an input to the authentication tag.
What we do not do with it
- It is never returned by any endpoint. The OpenAPI document is tested for this: no response schema in the published spec may name a password field.
- It is never written to the account record.
- It is never logged. Pino redaction covers
password,*.password,secret,*.secretand theauthorizationheader; on the runtime side the settings object's__repr__is overridden, because a traceback printing a frame's locals is the commonest accidental disclosure path. - It is never in an error message or an exception. Failures name a status code or an exception type, never a body.
- It is never passed to the container scheduler. See below.
We cannot show you your password. If it is lost, reset it at the broker.
How a runtime gets the password
A terminal has to log in, so something must hand it the credential. Every obvious way is disqualifying for a multi-tenant service:
| Method | Why not |
|---|---|
| Environment variable | Readable in docker inspect, in the task definition, and via /proc/<pid>/environ |
| Command line | Visible in ps to every process on the host |
| Mounted file | Lands on a volume that outlives the container |
| Queue payload | Persisted in Redis, replayed on retry, written to the RDB snapshot |
So the container is started with a ticket instead — a bearer token that is worthless on its own. The runtime presents it to the control plane over the private network and receives the password once, in a response body, straight into memory.
The ticket is:
- single-use — redeemed by a conditional
UPDATE, so two runtimes racing cannot both succeed, and a ticket found in a log is already spent - short-lived — five minutes, sized to a container start rather than a session
- scoped to one account — a compromised runtime cannot pivot to another
- stored as a digest — the ticket table is not a pile of live credentials
- revoked when the runtime it was minted for fails to start, or is stopped
Every redemption is audited, including refusals. Every refusal returns the same body: distinguishing "expired" from "unknown" from "wrong account" would be an oracle for probing valid ticket ids.
The link to a runtime
On one host the control plane and its runtimes talk over a private Docker network, and the ticket above is the authentication. That is a reasonable position when the traffic never leaves the machine, and it is the default.
Across hosts it is not, so the link can be made mutually authenticated. Both sides then hold a certificate from a private CA, and each checks the other:
./infra/tls/generate.sh /secure/tlsThat writes a CA and two ready-to-mount bundles. Mount bundle/control-plane
on API nodes and point MTLS_* at it; copy bundle/runtime to every host that
schedules runtimes and set RUNTIME_TLS_DIR to it. Nothing else is needed on a
runtime: its entrypoint starts a TLS terminator when it finds those three files
at /tls, and switches its own outbound calls to mutual TLS at the same time.
Both directions move together on purpose — a runtime that served TLS but
fetched its credential in the clear would be refused by the control plane and
never log in.
The CA key is not in either bundle, and that is the point of having
bundles. A runtime that can read the CA key can sign itself a control-plane
certificate, and the control plane is the one identity permitted to call the
endpoint that returns passwords. Keep ca.key where you keep the KEK — off
every host that runs customer traffic. The generator refuses to overwrite an
existing CA, and fails if a CA key ever reaches a bundle.
What this buys over the private network alone is the inbound direction. The credential endpoint returns an MT5 password, and "something on the overlay network" is a weaker claim to it than "a process holding a key we issued".
Two details that matter when reading the configuration:
Every runtime shares one certificate. Runtime containers are named for the
account they serve, which no certificate can enumerate and no wildcard covers,
so the control plane verifies against a fixed name
(RUNTIME_TLS_SERVERNAME) while connecting to the per-account host. This
authenticates "a genuine runtime", not "the runtime for account X" — that
question is answered by the fencing epoch, which is where it belongs.
Enabling it makes the API's own listener HTTPS, including for customer
traffic, so it expects a proxy in front holding a publicly-trusted certificate.
A client certificate is only required on /internal/*. CONTROL_PLANE_URL
must then be https://, and the node refuses to start otherwise: runtimes are
handed that address to fetch their credentials, and one dialling plain HTTP at
a TLS port boots, reports healthy and never logs in.
On the runtime host
MetaTrader offers exactly one way to log in without a human: an ini file passed
to terminal64.exe /config:<file>. There is no API that avoids putting the
password on a filesystem, so the job is to make that file as short-lived as the
mechanism allows.
It is written to tmpfs (/dev/shm, mounted noexec,nosuid, 64 MB) at mode
0600, and deleted in a finally — including when the launch raises. tmpfs
because the data volume outlives the container and can be snapshotted by the
host; a password written there is a password at rest in a place nobody is
watching.
This was verified rather than assumed. After a successful login against a live
broker, the password appeared in no file on the data volume, no file in tmpfs
(zero remaining), no log, no environment variable, and nothing in
docker inspect.
Tenant isolation
Every query naming a customer row carries the tenant in the WHERE clause
rather than checking ownership afterwards. Another tenant's account is not
found, not forbidden — a 403 would confirm that a guessed id exists.
The secret store repeats the check independently: get takes the tenant and
account as arguments and puts both in the query, so a stolen reference is not
enough on its own. That makes the store a second authorization boundary rather
than a lookup table.
Audit
Every credential read writes an audit row before the value is returned — awaited, not fired and forgotten. There is a real temptation to skip that on the latency-sensitive container-start path, but an audit trail that loses rows when a process restarts has holes in exactly the moments worth auditing.
Denied reads are audited too. The refusals are the rows worth having; a trail with only successes cannot answer whether anyone tried.
An operator can answer "who read this account's credentials, and when" from
audit_events alone.
What this does not protect against
Being specific here is more useful than a reassuring summary.
The KEK is in the API process's environment. It protects against a database
dump, which is the common breach. It does not protect against someone who
can read the process's memory, its deployment configuration, or the environment
of a compromised API node. Operators who need that property set
SECRETS_DRIVER=vault, where the key never leaves the secret service — the
envelope was designed for it, and the interface does not change.
A compromised API node can decrypt any secret it can reach. That is inherent in a service that must log accounts in. Tenant scoping constrains a stolen reference, not a compromised node.
We do not defend against your own key leaking. An API key with
accounts:write can disconnect accounts and erase credentials. Issue narrow
keys, rotate them, and keep them out of your repository.
TLS termination is the deployment's responsibility. The API speaks HTTP and
trusts X-Forwarded-*; it must sit behind a proxy that terminates TLS and
strips client-supplied forwarding headers.
/internal must not be routable from the internet. The ticket is the
authentication, not the obscurity — but an endpoint that returns credentials has
no business being publicly addressable, and ours is not.
Reporting a vulnerability
[email protected]. We will acknowledge within one business day. Please do not open a public issue.