Skip to main content
The bridge sits between a customer’s forecast data and a market submission that cannot be taken back after gate closure. Its security model is built around three questions: who is calling, what may they do, and what does the record show afterwards.

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

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 as name:secret, and several may be active per scope:
The name is what appears in the event log — the log says cloover posted a forecast, not merely that “a valid key” did. Rotation needs no downtime: add the new secret alongside the old, move the caller across, then drop the old entry. The single-key form (EXTERNAL_API_KEY, PROCESS_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 answers 503 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. 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’s EventSource 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:
The token is an HMAC over its own expiry, signed with a secret derived from the configured process keys — stable across replicas and restarts, useless after five minutes, and valid for the stream endpoint only. The API key is no longer accepted there.

What gets recorded

Every state-changing call and every refusal lands in the event log next to the pipeline events: Read-only traffic is not logged; it would bury the pipeline events the console exists to show. The health probe, the console page and the stream-token exchange are excluded for the same reason.

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 run az 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 the authorization 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.
CI regenerates the locks and fails if they differ from what is committed, so the pins cannot drift away from the ranges unnoticed. Without --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.

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 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.
  • The ACR pull still uses the registry’s admin password rather than the managed identity.