FLIP uses AWS RDS PostgreSQL with the following version support policy:
- Current Version: PostgreSQL 17 (EOL: November 2029) ✓
- Minimum Version: PostgreSQL 15
- Deprecated: PostgreSQL 13 (EOL: November 2025) ❌ EXPIRED
Version Lifecycle:
| Version | EOL | Status |
|---|---|---|
| PostgreSQL 13 | November 2025 | ❌ EXPIRED — do not use |
| PostgreSQL 14 | October 2026 | ❌ EXPIRED — do not use |
| PostgreSQL 15 | October 2027 | |
| PostgreSQL 16 | October 2028 | ✓ Supported |
| PostgreSQL 17 | November 2029 | ✓ Current (Terraform default) |
Upgrade Policy: Plan PostgreSQL major version upgrades with a 6-month lead time before EOL. AWS charges premium rates for EOL versions. To change the version, update the postgres_version variable in deploy/providers/AWS/variables.tf.
Log in to GHCR to pull pre-built images from CI/CD:
Create a token with read:packages scope from GitHub settings.
We recommend following GitHub's guide.
echo <GITHUB_PAT> | docker login ghcr.io -u <GITHUB_USERNAME> --password-stdinNote: You need a GitHub Personal Access Token (PAT) with
read:packagespermission.
Set up AWS CLI with SSO for authentication:
aws configure ssoif you have already configured SSO, you can then login with:
aws sso loginGenerate an SSH key pair for EC2 instance access:
ssh-keygen -t ed25519 -f ~/.ssh/host-aws -C "YOUR_EMAIL@example.com"This key will automatically be uploaded to AWS during deployment and can be found in the AWS console under AWS EC2 > Network & Security > Key Pairs.
See TF_VAR_flip_keypair and TF_VAR_ec2_public_key_path in the Terraform environment configuration if you need to customize the key name or path.
The SES email address will have received a verification link you need to click. Then, to check the email has been verified, log in to the AWS console, navigate to the SES service, and check the Configuration > Identities section.
FLIP uses AWS Cognito for user authentication and includes branded email templates for temporary password invitations and password reset flows. The email templates are deployed as part of the Terraform infrastructure.
Email Templates:
-
Temporary Password Email (
admin_create_user_config.invite_message_template)- Sent when administrators invite users to FLIP
- Contains: Username and temporary password
- Uses Cognito placeholders:
{username}— User's Cognito username{####}— 6-character temporary password
-
Password Reset Email (
verification_message_template)- Sent when users request password reset
- Includes both verification code and reset link options
- Uses Cognito placeholders:
{####}— 6-character verification code{##...##}— Dynamically generated password reset link token
SES Email Verification Requirement:
Before deploying Cognito email templates, the SES email address must be verified:
cd deploy/providers/AWS
# For initial deployment or if verification has expired:
# 1. Delete the existing SES identity in AWS Console (if expired):
# - Navigate to AWS SES > Configuration > Identities
# - Delete the FLIP email identity
# 2. Re-verify the email address
# - Run: make plan apply
# - Check your email inbox for AWS SES verification link
# - Click the link to confirm the email address
# 3. Verify the configuration:
# - Return to AWS SES > Identities
# - Confirm the email status shows "Verified"Testing Email Delivery:
After deployment, test the email configuration by creating a test user:
# Get the Cognito user pool ID
USER_POOL_ID=$(cd deploy/providers/AWS && terraform output -raw cognito_user_pool_id)
# Create test user (suppress automatic email)
aws cognito-idp admin-create-user \
--user-pool-id "$USER_POOL_ID" \
--username testuser@example.com \
--message-action SUPPRESS
# Verify the temporary password email is received with correct formatting
# (Check inbox for email from FLIP with temporary credentials)
# Test password reset flow
# 1. Login as testuser with temporary password
# 2. Change password to permanent one
# 3. Logout and request password reset
# 4. Verify password reset email arrives with verification code or reset link
# Verify email rendering across clients:
# - Gmail
# - Outlook
# - Apple Mail
# - Mobile email clientsManual Verification Checklist:
- SES email identity shows "Verified" status in AWS Console
- Temporary password email includes username and temporary password
- Password reset email includes 6-digit verification code or reset link
- Email templates render correctly in Gmail, Outlook, Apple Mail
- Links in email templates resolve to correct environment subdomain (e.g.,
https://flip-staging.example.com) - SMS fallback messages deliver (if SMS is enabled in Cognito)
FLIP enforces TOTP (Time-based One-Time Password) MFA for every Cognito user. The enforcement lives in the application layer, not in the Cognito pool configuration — the pool's mfa_configuration is deliberately set to OPTIONAL. This section explains why, how administrators reset MFA for other users, and how an administrator who has lost their own authenticator can recover via the AWS CLI.
Cognito exposes no admin API to delete a user's verified TOTP secret. With the pool set to ON, calling AdminSetUserMFAPreference with Enabled=False leaves the old secret registered in Cognito — at the next sign-in, Cognito still issues a SOFTWARE_TOKEN_MFA challenge and asks the user for a code their (lost) authenticator can no longer generate. The account becomes permanently locked out short of deleting and recreating the user.
With mfa_configuration = "OPTIONAL", disabling the preference actually takes effect: Cognito signs the user in without a challenge. The application layer then catches the user — flip-api verify_token checks whether SOFTWARE_TOKEN_MFA is present in the user's UserMFASettingList and returns 403 if not, and the flip-ui router guard routes the user to the post-auth enrolment page where they mint a fresh TOTP secret. First-time users follow the same path: they sign in with their temporary password, the app sees mfaEnabled=false, and they are walked through enrolment before any protected route is reachable.
SMS MFA is intentionally disabled — it would introduce an SNS dependency and reintroduce SIM-swap risk. The rationale is documented inline at deploy/providers/AWS/modules/cognito/main.tf (around the mfa_configuration line) and the enforcement point lives in flip-api/src/flip_api/auth/dependencies.py (verify_token).
The MFA gate is controlled by flip-api's ENFORCE_MFA setting. The Settings default is true — when active, verify_token returns 403 for any signed-in user without an active TOTP and the UI's router guard redirects them through enrolment.
The dev override lives in deploy/compose.development.yml (ENFORCE_MFA=false) so local development doesn't force enrolment on a burner authenticator. The flag is intentionally not exposed in .env.development.example or AWS Secrets Manager — the Settings default (true) is the canonical secure anchor. deploy/compose.production.yml passes ENFORCE_MFA=${ENFORCE_MFA:-true} so operators can override it from .env.stag/.env.production for testing (e.g. ENFORCE_MFA=false), but it falls back to the secure true default when unset — do not commit an override into either env file for a real deployment.
The flag is mirrored to the UI via /users/me/mfa/status (required: bool) so the router guard knows when to skip the enrolment redirect.
For users other than yourself, use the FLIP Admin UI. See the Reset User MFA subsection in docs/source/sys-admin/admin-project-and-user-management.rst for the step-by-step flow. The UI calls POST /users/{user_id}/mfa/reset on flip-api, which runs the same two Cognito operations documented below but under the FLIP permission model (requires CAN_MANAGE_USERS) and leaves an application-level audit trail.
This runbook is for the case where you have lost access to your TOTP device and the UI flow above is therefore unavailable (you cannot sign in to reach the Admin Area). Another operator with AWS credentials runs these commands on your behalf. Direct AWS CLI access is required:
Prerequisites:
- AWS credentials for the account that hosts the Cognito user pool (the same SSO profile used for
make full-deploy) - IAM permissions for
cognito-idp:AdminSetUserMFAPreferenceandcognito-idp:AdminUserGlobalSignOut
Steps:
-
Fetch the Cognito user pool ID from Terraform (or read it from the AWS Console):
# From the stack the admin belongs to — prod/stag root or dev root cd deploy/providers/AWS # or deploy/providers/AWS/dev tofu output -raw cognito_user_pool_id
-
Clear the locked-out administrator's TOTP preference:
aws cognito-idp admin-set-user-mfa-preference \ --user-pool-id "$USER_POOL_ID" \ --username admin@example.com \ --software-token-mfa-settings Enabled=false,PreferredMfa=false -
Revoke the administrator's existing refresh tokens so no pre-reset session can keep operating:
aws cognito-idp admin-user-global-sign-out \ --user-pool-id "$USER_POOL_ID" \ --username admin@example.com -
The administrator now signs in with their existing password. Because
SOFTWARE_TOKEN_MFAis no longer in theirUserMFASettingList, theflip-apiMFA gate and theflip-uirouter guard funnel them through the post-auth enrolment page where they register a new authenticator. Their password does not need to be reset.
Note: These two CLI commands have exactly the same server-side effect as clicking Reset MFA in the Admin UI — the UI endpoint (
reset_user_mfainflip-api/src/flip_api/utils/cognito_helpers.py) callsadmin_set_user_mfa_preferencefollowed byadmin_user_global_sign_out. The CLI path exists only because it does not require a signed-in FLIP session.Warning: This path is an AWS-level escape hatch and is not audit-logged inside FLIP. Use it only for administrator self-recovery. For any user who is not currently locked out of FLIP itself, prefer the Admin UI flow so the reset is captured in the application logs.
The Central Hub has one supported production deployment: ECS Fargate via the Terraform root in
deploy/providers/AWS/. The task definitions in ecs_tasks.tf (env maps in
locals.tf) are the canonical definition of production container config. Deploying into an AWS
LZA-governed account is an env-gated mode of that same root, not a separate path
(FLIP#749). The ECS FL task definitions serve both
FL backends (FLIP#566): FL_BACKEND in the env file
switches the same task families between NVFLARE and Flower (SuperLink ports/command/creds — Flower
additionally needs FLOWER_KIT_DATE and provisioned creds uploaded via
make -C fl-services/flower provision upload-creds-to-s3, with FLOWER_EXTRA_SERVER_SANS covering the
Cloud Map + public FL hostnames).
Deprecated — hub on EC2/compose (FLIP#936): running the hub via
compose.production*.ymlon an EC2 or self-managed host is no longer a supported deployment target (the former hub EC2 host was long since replaced by a minimal SSM bastion). Thecompose.production*.ymlfiles remain maintained only as the local prod-image harness (make up PROD=stag|true— the baked images, no dev mounts). When changing production config, change Terraform first and update the compose files only as far as the local harness needs. Remaining hub-EC2 material is removed once the LZA migration's legacy decommission lands (FLIP#749 WP6).
FLIP supports three trust deployment models:
| Model | Location | Documentation |
|---|---|---|
| Cloud (EC2) | AWS EC2 (same account as Central Hub) | deploy/providers/AWS/README.md |
| Hybrid / On-Premises | Any Ubuntu host (home lab, hospital server) | deploy/providers/local/README.md |
| Kubernetes | Any K8s cluster 1.28+ (EKS, AKS, on-prem) | deploy/providers/kubernetes/README.md |
In all models, trusts poll the Central Hub for tasks over HTTPS — all communication is outbound from the trust. The hub never makes inbound requests to trusts.
All Docker containers in FLIP are hardened with the following measures:
Each Dockerfile explicitly drops root privileges by running the application as a non-root user:
| Service | User | Notes |
|---|---|---|
| flip-api | app (UID 49999) |
Set in the flip-api Dockerfile |
| flip-ui | root |
Intentionally runs as root in dev (Vite dev server with bind-mounted source); not present in production where the UI ships as static files served by CloudFront. |
| orthanc | orthanc (UID 999) |
Set via USER orthanc in the orthanc Dockerfile; the user pre-exists in the orthancteam/orthanc base image |
| xnat-web | xnat |
Created in the XNAT Dockerfile (UID 1001) |
| xnat-nginx | nginx |
Pre-existing in the base image (nginx) |
| xnat-db | postgres |
Pre-existing in the base image (postgres) |
| xnat-socket-proxy | root |
Upstream tecnativa/docker-socket-proxy image — HAProxy connects to the root-owned Docker socket as its owner. Runs under cap_drop: ALL with no capabilities added back. |
| flip-db / omop-db | postgres |
Pre-existing in the base image (postgres) |
Bind-mount ownership. Because XNAT (xnat, UID 1001) and Orthanc (orthanc, UID 999) no
longer run as root, the host-side bind-mount source directories must be owned by the matching
UID. The Ansible playbooks deploy/providers/AWS/site.yml and
deploy/providers/local/site_local_trust.yml provision /opt/flip/xnat/** as UID 1001 and
/opt/flip/orthanc/** as UID 999 — including a recursive chown after extracting the Orthanc
storage archive (which tar writes as root). If you provision a trust host outside Ansible, you
must replicate this ownership or first-boot writes (archive ingest, SQLite index, log rotation)
will fail with EACCES. In dev, trust/orthanc/update_orthanc_data.sh instead chmods the mock
storage world-writable so a developer needs no sudo to re-seed it.
Every container drops all Linux capabilities (cap_drop: [ALL]) and only adds back what the
service strictly requires. Two dev-only services (pgadmin, register-supernode-keys) are
deliberately exempted because their entrypoints depend on root capabilities that would crash-loop
under cap_drop: ALL. The standalone fl-services/nvflare/compose.dev.yml dev harness's
fl-client-1/fl-client-2 — used only by make -C fl-services/nvflare up for iterating on the
backend outside the full trust stack — carry no cap_drop either, but for a different reason:
they are simply not hardened yet, not blocked from it. They run the same non-root
flare-fl-client image and the same kit dirs as the hardened fl-client-net-* services below, so
a later pass can harden them the same way. The trust-deployment fl-client-net-* services (in
trust/deploy/compose_trust.*.yml, what a real trust actually runs) are hardened — see the rows
below. The per-service grants in the compose files are:
| Service(s) | Granted capabilities | Reason |
|---|---|---|
| flip-api, fl-api (Flower), trust-api, imaging-api, data-access-api, xnat-web, loki, alloy, grafana | CHOWN |
In-container init/entrypoint fixes ownership on volume paths it owns. |
| fl-client-net-* (Flower — production and development; NVFLARE development) | (none) | Runs non-root (GHSA-8465), and Docker grants effective capabilities only to root — a cap_add here would land in the bounding set with CapEff still 0, so it would buy nothing. Flower's flower-supernode entrypoint does no chmod/chown at all and its dev mounts are :ro. NVFLARE's dev kit (provision/workspace-dev/) is written by make provision as the host user whose UID is baked into the :dev image, so ownership already matches; when it doesn't (CI, a shared devbox, a teammate's prebuilt image) the fix is to chown the kit dirs to the container UID, not to grant a capability. |
| fl-client-net-* (NVFLARE, production) | DAC_OVERRIDE, FOWNER (production) |
Inert for the current image, which runs non-root from PID 1 — the Ansible-provisioned FL_KIT_DIR is pre-chowned to the container's UID by site.yml / site_local_trust.yml. Kept for legacy root-image compat: trusts pin DOCKER_FL_TAG (an immutable sha tag is the documented norm), so --pull always cannot move a trust off a pre-GHSA-8465 root image, and under cap_drop: ALL such an image loses root's implicit DAC bypass on the envsubst write into the bind-mounted local/ and FOWNER on the chmod +x of startup/*.sh. Its writes predate the || exit 1 guard, so it degrades to running NVFLARE against a stale/absent resources.json rather than crash-looping — a worse failure to diagnose. Same rationale as the orthanc row below. |
| fl-api (NVFLARE) | CHOWN |
The flare-fl-api image runs as user flip (UID 1001, non-root), so only the CHOWN baseline is needed; DAC_OVERRIDE and FOWNER are inert for non-root processes. |
| fl-server (NVFLARE) | CHOWN, DAC_OVERRIDE, FOWNER |
The container runs as root, but the provisioned NVFLARE kits are bind-mounted owned by the provisioning uid with 0600 keys. cap_drop: ALL strips root's implicit DAC bypass, so without DAC_OVERRIDE the fl-server crash-loops on /app/startup/server.key; the entrypoint also chmods kit scripts it does not own (FOWNER). In dev, the same grant lets the root fl-server read the operator's 0600 AWS SSO token cache for the S3 results upload. |
| fl-server (Flower, development only) | CHOWN, DAC_OVERRIDE |
The dev compose runs the SuperLink as root (see the user: "0:0" comment in compose.development.flower.yml) to read the host-provisioned 0640 TLS keys and the operator's 0600 SSO token cache; cap_drop: ALL strips root's implicit DAC bypass, so DAC_OVERRIDE is granted back. Production runs the image's non-root user with instance-role AWS credentials and keeps the CHOWN baseline. |
| flip-db, omop-db, xnat-db | CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID |
The official postgres entrypoint runs as root and gosu-drops to the postgres user (SETUID/SETGID). On every start it re-runs chmod 00700 $PGDATA and a chown sweep over the persisted data dir, which on subsequent boots is already owned by postgres with mode 0700 — chmod on a dir owned by another uid needs FOWNER and traversing it needs DAC_OVERRIDE. Without them the container exits 1 on a persisted volume (see commit history, FLIP#485). |
| orthanc | CHOWN, DAC_OVERRIDE, FOWNER (production) |
Runs non-root (UID 999) from PID 1, so DAC_OVERRIDE/FOWNER are inert for the current image — they're kept for legacy root-image compat, since a pre-hardening orthanc image runs its entrypoint as root and crash-loops under cap_drop: ALL without them (hostid write, plugin symlink fixup). The storage bind mount is made 999-writable at the provisioning layer rather than fixed up with in-container caps; CHOWN is kept as the shared baseline. |
| xnat-nginx | CHOWN, NET_BIND_SERVICE |
NET_BIND_SERVICE lets the non-root nginx user bind port 80 (Docker sets net.ipv4.ip_unprivileged_port_start=0, but capabilities are still checked). |
| xnat-socket-proxy | (none) | HAProxy runs as root, which owns the mounted Docker socket, so it connects with an empty capability set — nothing needs adding back on top of cap_drop: ALL. See Docker Socket Isolation. |
| flip-ui (development only) | CHOWN, DAC_OVERRIDE, SETUID, SETGID, NET_BIND_SERVICE |
Vite dev server with bind-mounted source; not present in production where the UI ships as static files served by CloudFront. |
Notably not granted anywhere: SYS_ADMIN, SYS_PTRACE, SYS_MODULE, MAC_*, AUDIT_*,
DAC_READ_SEARCH. Combined with no-new-privileges (below), this defeats the standard
setuid-binary and LD_PRELOAD escalation paths even if an attacker achieves RCE inside a
container.
Hardened images and hardened compose must ship together. The minimal-caps compose files assume the rebuilt non-root
orthancandxnat-webimages. Until CI publishes those (on merge todevelop/main), the previously published root-running GHCR images break under the new caps: orthanc crash-loops writing itsorthanc-owned/etc/hostid(root withoutDAC_OVERRIDE), and xnat-web — still root — cannot write its UID-1001 bind mounts (config/archive/build/cache/plugins), somake up's XNAT configure step fails on both trusts (swarm enforcescap_dropeven though it ignoressecurity_opt). When running this hardening from a branch whose images are not yet published, build the hardened images locally:make up BUILD=truecovers the compose-built services (including orthanc), and the XNAT stack needsmake -C trust/xnat build DOCKER_REGISTRY= DOCKER_TAG=devfollowed by redeploying the stack with the sameDOCKER_REGISTRY=/XNAT_TAG=devoverrides sodocker stack deployresolves the locally built image instead of the GHCR one.
XNAT's Container Service plugin launches processing containers — currently xnat/dcm2niix,
which every project created with DICOM→NIfTI conversion enabled triggers automatically on scan
archive. It used to do this through /var/run/docker.sock mounted straight into xnat-web,
which is a root-equivalent capability: anything that compromises XNAT can exec into any container
on the host, start privileged containers, or mount arbitrary host paths.
xnat-web no longer mounts the socket. The XNAT stack instead runs a
tecnativa/docker-socket-proxy sidecar
(xnat-socket-proxy in trust/xnat/docker-compose-stack.yml) that holds the socket read-only
and serves a filtered Docker API (tcp://xnat-socket-proxy:2375) on a dedicated stack-scoped
overlay network (<stack>_socket-proxy, internal: true, no published host port). Only
xnat-web is dual-homed onto that network — the other containers on the shared trust overlay
(trust-api, imaging-api, data-access-api, orthanc, the fl-client) have no route to the
proxy at all. The Container Service is pointed at that endpoint by
trust/xnat/xnat/config/container-service-backend-configuration.json, which
configure-dcm2niix.sh POSTs to /xapi/docker/server — and the configure run now hard-fails if
/xapi/docker/server/ping cannot reach Docker through the proxy, instead of leaving a
registered-but-unlaunchable dcm2niix command behind.
The proxy allowlists only what a swarm-mode Container Service launch needs — SERVICES
(+ POST for the mutating calls), TASKS, NODES, IMAGES (pull-on-init), INFO, SWARM
(the plugin's swarm-mode connection test is a GET /swarm inspect), plus the proxy's default
PING/VERSION/EVENTS. Everything else is refused with a 403: exec, the container API,
volumes, networks, secrets, configs, plugins, and build.
Multiple trusts on one host. Each trust's XNAT is a separate swarm stack (xnat<N>), so each
gets its own proxy on its own xnat<N>_socket-proxy network — they are not shared, which
keeps the trust boundary intact. The identical xnat-socket-proxy service name in both stacks
does not collide: swarm resolves it per-network, so each xnat-web reaches only its own proxy
(cross-stack access is refused), and the proxy publishes no host port. Both proxies holding the
same host socket is fine — it serves concurrent clients, exactly as it did when each xnat-web
mounted it directly.
This is a choke point, not a sandbox. Two residual powers are worth stating plainly: the services
API the Container Service depends on can itself create a service with an arbitrary host bind
mount, and because the proxy's POST switch is global, granting SWARM also exposes
POST /swarm/*. So a fully compromised xnat-web is not contained — but it loses direct
container/exec access entirely, and every remaining call now crosses one auditable gateway
(docker service logs <stack>_xnat-socket-proxy shows the full request log) instead of speaking
to the raw socket.
Nearly every container declares security_opt: [no-new-privileges:true] to prevent privilege escalation
via setuid binaries or LD_PRELOAD injection. The same dev-only services exempted from cap_drop
(pgadmin, register-supernode-keys, fl-clients) also omit no-new-privileges, and the XNAT swarm
stack ignores the security_opt key (see caveat below).
XNAT swarm caveat. The XNAT stack (
xnat-web,xnat-db,xnat-nginx,xnat-socket-proxy) is deployed withdocker stack deploy(seetrust/xnat/Makefile), and swarm ignores thesecurity_optkey — sono-new-privilegesis not applied to those four services from the compose file. Thecap_drop/cap_addhardening still takes effect (swarm honours those). To enforce no-new-privileges on XNAT hosts, set it as the Docker daemon default in/etc/docker/daemon.json:{ "no-new-privileges": true }. Verify with thedocker inspectcommand below.
To verify hardening on a running container:
# Check the running user (should NOT be root)
docker exec <container> whoami
# Check effective capabilities
docker exec <container> cat /proc/1/status | grep CapEff
# Confirm no-new-privileges is active
docker inspect <container> --format '{{.HostConfig.SecurityOpt}}'FLIP uses three separate authentication mechanisms for service-to-service communication. The single hub-internal key is generated standalone; the two per-trust keys are minted together when you register each trust:
make generate-internal-service-key # fl-server → flip-api (single hub-internal secret)
make register-trusts # mints each trust's TRUST_API_KEY + TRUST_INTERNAL_SERVICE_KEY into its kit fileEach trust has a single TRUST_API_KEY (plaintext) minted by register_trust (make register-trusts) and held
only in that trust's kit file (trust/.env.<CODE>.<env>), sent in the TRUST_API_KEY_HEADER header. The hub stores
only the SHA-256 hash, in the trust table's api_key_hash column — there is no hub-side env dict of trust keys.
Used for task polling, cohort result submission, and heartbeat endpoints.
The fl-server on the Central Hub authenticates to flip-api using INTERNAL_SERVICE_KEY (a plain string) sent
in the INTERNAL_SERVICE_KEY_HEADER header. The hub validates it against INTERNAL_SERVICE_KEY_HASH (the
SHA-256 hash of the key). Used for model status updates, training metrics, and training log endpoints. This
is a single, hub-internal secret and is separate from the per-trust internal keys below.
Inside each trust, every call from trust-api / imaging-api / fl-client to imaging-api or data-access-api
carries a shared-secret header. The header name comes from TRUST_INTERNAL_SERVICE_KEY_HEADER (default
X-Trust-Internal-Service-Key); the value is the per-trust plaintext TRUST_INTERNAL_SERVICE_KEY, minted by
register_trust into the trust's kit file (trust/.env.<CODE>.<env>) alongside TRUST_API_KEY. Receivers compare
the header against their own copy with a constant-time compare. /health is intentionally exempt so liveness
probes still work.
Each trust gets a distinct key — a leak in Trust_1 cannot drive operations on Trust_2's APIs. The hub
never sees these keys: they live only in trust-side env (the trust's kit file trust/.env.<CODE>.<env>, which
trust/Makefile -includes so every trust-internal container inherits it). See the
public security model for the full threat model.
FL clients (trust side) do not have Central Hub API credentials. Only the fl-server communicates with flip-api. FL clients relay metrics and exceptions to the fl-server, which forwards them to the Central Hub.
The fl-server must reach flip-api via FLIP_API_INTERNAL_URL — a Docker-network URL such as
http://flip-api:8000/api. It must not go through the public CENTRAL_HUB_API_URL because the
CloudFront distribution in front of flip-api whitelists only Authorization, Content-Type, and
Origin and strips X-Internal-Service-Key at the edge, which would break this handshake. The
public CENTRAL_HUB_API_URL is reserved for flip-ui and trust-side (trust-api) consumers that live
outside the hub's Docker network.
| Variable | Where used | Purpose |
|---|---|---|
TRUST_API_KEY_HEADER |
flip-api, trust-api | Header name for trust auth |
TRUST_API_KEY |
trust-side kit file | Per-trust plaintext key (hub stores its SHA-256 in the trust table's api_key_hash) |
INTERNAL_SERVICE_KEY_HEADER |
flip-api, fl-server | Header name for internal service auth |
INTERNAL_SERVICE_KEY |
fl-server | Internal service plaintext key (plain string) |
INTERNAL_SERVICE_KEY_HASH |
flip-api | SHA-256 hash of internal service key (plain string) |
TRUST_INTERNAL_SERVICE_KEY_HEADER |
trust-api, imaging-api, data-access-api, fl-client | Header name for trust-internal service auth (default X-Trust-Internal-Service-Key) |
TRUST_INTERNAL_SERVICE_KEY |
trust-side kit file | Per-trust plaintext key, minted by register_trust. The hub never sees it. |
CENTRAL_HUB_API_URL |
flip-ui, trust-api | Public base URL of flip-api (in prod: CloudFront URL) |
FLIP_API_INTERNAL_URL |
fl-server | Docker-network URL of flip-api on the Central Hub (e.g. http://flip-api:8000/api) |
The Central Hub runs on ECS Fargate (see Deployment Models).
FLIP_API_INTERNAL_URL names the intent ("flip-api's internal URL on the Central Hub"), not the
mechanism: on ECS, Terraform sets it to the Cloud Map private-DNS name
(http://flip-api.flip.local:8000/api, built in locals.tf); on the local compose harness it is the
Docker-network URL (http://flip-api:8000/api).
What it must not be: the public CloudFront URL. That's orthogonal to compute — CloudFront strips
X-Internal-Service-Key at the edge. Internal ALBs preserve all request headers by default, so an
internal-ALB value would also work; CloudFront doesn't.
Note on troubleshooting: AWS-deployment-specific failure modes (Terraform state drift, ECS service stuck in
PENDING, CloudFront cache invalidation, RDS connectivity) are documented inproviders/AWS/TROUBLESHOOTING.md.