Credentials
Two credential types are accepted. Endpoints require a scope, never a particular credential, so a caller can move from one to the other without an endpoint change or a flag day.API key
X-API-Key: <secret>Named per caller, several per scope, compared in constant time.
The default for machine callers today.OIDC bearer token
Authorization: Bearer <jwt>RS256, verified against the issuer’s JWKS. For OAuth2
client-credentials clients (Auth0 machine-to-machine).Scopes
report is therefore not a read-only key: give it only to whoever may
influence the trader’s backup files. OIDC tokens can carry external and
process only (OIDC_SCOPE_EXTERNAL, OIDC_SCOPE_PROCESS).
A credential that authenticates but lacks the scope is refused with 403,
distinct from the 401 of a missing or invalid credential. The distinction
matters when reading the log: one is a misconfigured client, the other is
someone knocking.
Named keys and rotation
A key is configured asname:secret, and several may be active per scope:
EXTERNAL_API_KEY, PROCESS_API_KEY, REPORT_API_KEY,
SANDBOX_API_KEY, DASHBOARD_API_KEY) still works and takes the scope as its caller name.
OIDC / OAuth2 client credentials
Set an issuer and an audience and the service additionally accepts bearer tokens:
What is verified: the RS256 signature against the issuer’s published keys,
the audience, the issuer, and the expiry. The algorithm is pinned — an
alg: none or an HMAC-signed token is refused whatever its header claims.
Scopes are read from the scope claim or from Auth0’s permissions claim,
so both plain OAuth2 scopes and Auth0 RBAC work. The signing keys are
cached and refetched when a token presents an unknown kid, so the issuer
can rotate its keys without a restart here.
Leaving OIDC_ISSUER unset disables the path entirely: a bearer token is
then not a credential at all, it is ignored.
The verification path is covered by tests against a locally generated
signing key — signature, audience, issuer, expiry, algorithm pinning,
scope mapping and key rotation. It has not yet been run against a live
tenant; that needs the issuer and audience of the tenant in question.
Fail closed
A service that cannot tell its callers apart serves nobody. With no API key and no OIDC issuer configured, every authenticated route answers503 and
names the missing configuration. /health stays open so the platform can
still probe and schedule the container.
ALLOW_UNAUTHENTICATED=true turns that off for local development. It is
never set by the deployment template, and the service emits a warning event
on every start when it is on.
Rate limiting
A token bucket per caller:RATE_LIMIT_PER_MINUTE (default 120) sustained,
RATE_LIMIT_BURST (default 60) in a burst. Exceeding it returns 429 with
a Retry-After header. Setting the rate to 0 disables it. It covers the
external, report and process routes, and the console API (/v1/console/*)
with the same limits in a bucket of its own: the cron, the routines and the
console often share one process key, and a caller draining the process
bucket must not lock the operator out of the status view and the Stop
button. The console polls two endpoints every ten seconds and shows a
throttled request as such, not as a rejected key.
The bucket lives in the process, so with several replicas the effective
limit is per replica. It exists to keep one runaway client — or a
credential-guessing loop — from consuming the instance; a global quota
belongs in a gateway in front of the service.
The console stream
The browser’sEventSource cannot set request headers, which is the usual
reason a key ends up in a query string, where gateways and platform logs
record it in plain text. Instead the console exchanges its key for a
short-lived token:
What gets recorded
Every state-changing call and every refusal lands in the event log next to the pipeline events:
A refusal records everything the request says about its sender, in the
event’s
data.origin:
ip: the client’s address. The platform’s ingress appends it toX-Forwarded-For, so the chain is read from the right, past the platform’s own hops (100.64.0.0/10).forwarded_forkeeps the whole chain (entries further left are the client’s own claim) andpeerthe address the request came from, which is the ingress.credential: where a credential was presented (X-API-Key,Bearer,URL) and whose it is. A key is looked up even where the route never checked it, for example on a wrong method (405). An unknown key or a bearer token is recorded only as a 12-digit fingerprint (SHA-256), never as the secret itself.user_agent,queryandheaders(at most 40). Values of headers and query parameters named like a credential (auth,key,token,cookie,secret,signature,session,password) are masked.
Secrets at rest
By default the Bicep template stores the secrets as Container Apps secrets. Those are encrypted at rest but readable to anyone who can runaz containerapp show --show-secrets on the resource group.
Deploying with useKeyVault=true instead creates a Key Vault and a
user-assigned managed identity, writes the secrets there, and leaves only
references on the container app. Access then goes through the identity and
is auditable in the vault’s own log.
Outbound
The call to Engrate uses their scheme — the API key raw in theauthorization header, over TLS, per their
authentication guide. The
key is held as a deployment secret and is never written to the repository,
the audit trail or the event log; the console masks it.
Hardening
Beyond authentication, the checks that a review asks about:Dependencies
pyproject.toml states what the service is compatible with;
requirements.lock and requirements-dev.lock state what it is actually
built and tested against — every package pinned to an exact version and
verified by hash, transitive dependencies included. The image installs from
the lock with --require-hashes, so a tampered or substituted package
fails the build rather than reaching production.
--upgrade existing pins are kept, so that check does not turn red merely
because something was published upstream this morning.
Keeping the pins moving
Pinning freezes the versions, which is the point — and it also means a patched dependency no longer arrives on its own. Two mechanisms cover that:
Python is deliberately not left to Dependabot: it detects requirements
files by a
*.txt glob and regenerates pip-compile output from a .in
input, neither of which matches a hash-verified .lock compiled from
pyproject.toml. It would bump a pin and leave the hashes stale — a pull
request that cannot build. The workflow uses the project’s own script, so
what lands is a correct lock or a clearly-labelled failure.
Opening the pull request needs Settings → Actions → General → “Allow
GitHub Actions to create and approve pull requests”, which is off by
default and a reasonable thing to leave off. With it off the workflow
still upgrades, tests and pushes the branch; the run stays green and the
job summary carries the branch name, the test verdict and a link to
open the pull request by hand.
Known limits
Stated plainly, because a security page that lists only strengths is not worth reading:- The rate limit is per replica, not global.
- The console keeps the process key in the browser tab’s
sessionStorage, gone when the tab closes. Earlier versions kept it inlocalStorage; the console deletes that copy the next time it is opened, but a browser that never opens it again still holds the key — rotate the process key if that browser is out of your control. - The interactive API docs (
/docs,/redoc) and/openapi.jsonlist every internal route and are served only withAPI_DOCS_ENABLED=true(local development). The published specs are on the documentation site. - The console is served by the same app as the API; it is protected by the process scope, and anyone holding that credential can change runtime configuration.
- The audit trail and event log are files on an Azure Files share; their integrity depends on the storage account’s access control, and they are not signed or append-only at the storage layer.