- API Portal
- next
- Deployment
Security Hardening¶
Everything on this page applies to a single-instance production deployment as much as to a replicated one. The parts specific to running more than one pod are called out where they arise.
Secrets¶
Nothing generates secrets at startup. They are provisioned once, before first boot, and delivered to every instance:
The chart never creates or embeds secret values. Provision them with the bundled generate-secrets.sh, then point the release at the Secret it wrote:
Rendering fails if existingSecret is unset, so there is no path to starting a release without them.
scripts/setup.sh writes them as files under resources/, which the compose file mounts into the container. They are read through {{ file }} tokens rather than environment variables deliberately, so they never appear in docker inspect or a process environment dump:
| File | Purpose |
|---|---|
resources/keys/api-portal-encryption.key |
At-rest encryption key |
resources/keys/api-portal-session-secret |
Session-signing secret |
resources/certificates/cert.pem + key.pem |
TLS pair |
Run the script on one VM and copy resources/keys/ and resources/certificates/ to the others. Running it separately per VM generates different values on each — see the warning below.
The values themselves are the same on either substrate:
| Secret key / env var | Required | Purpose |
|---|---|---|
APIP_AP_SECURITY_ENCRYPTION_KEY |
Always | Encrypts stored credentials at rest |
APIP_AP_SECURITY_SESSION_SECRET |
Always | Signs session cookies |
APIP_AP_DATABASE_PASSWORD |
External database | Database login |
APIP_AP_AUTH_IDP_CLIENT_SECRET |
idp mode |
OIDC client secret |
jwt_public.pem |
Local auth only | Mounted as a file, not an env var — the Platform API's RS256 public key. Not needed in production, where login goes to your identity provider. |
On Kubernetes the optional keys are wired only when the matching flag is true — secrets.hasIdpClientSecret, secrets.hasPublicKey — and generate-secrets.sh sets these in the values file it writes.
Two secrets must be byte-identical on every instance
A mismatched APIP_AP_SECURITY_SESSION_SECRET means each instance rejects cookies signed by the others, so users are logged out at random as the load balancer moves them around. A mismatched APIP_AP_SECURITY_ENCRYPTION_KEY means credentials written by one instance can't be decrypted by another — intermittent failures that read as data corruption.
On Kubernetes, referencing one Secret from the release guarantees it. On VMs it is on you: generate once, then copy resources/keys/ to every host. Both provisioning scripts leave existing material untouched when re-run, so upgrades don't rotate secrets out from under running instances.
Rotating the encryption key requires re-encrypting stored credentials. Treat it as a planned migration, not a routine rotation.
TLS configuration¶
The portal image does not generate a certificate for itself. Supply a real one, or terminate TLS in front of it.
api-portal-ui:
tls:
certificateProvider: cert-manager # cert-manager | secret | none
mountPath: /app/certs
| Provider | Use when |
|---|---|
cert-manager |
cert-manager runs in the cluster. Set tls.certManager.issuerRef, commonName, and dnsNames to your real hostname — the shipped defaults point at devportal.localhost and are development values. |
secret |
You hold a certificate already. Set tls.secret.name and, if your keys differ, certKey / keyKey. |
none |
A TLS-terminating proxy or ingress fronts the portal. The listener then serves plain HTTP. |
selfSigned is deliberately not supported — the image has no in-container certificate generation, so the chart rejects it at render time rather than starting a pod that can't serve HTTPS.
setup.sh writes a self-signed pair to resources/certificates/, which the compose file mounts at /etc/api-portal/tls. That is fine for evaluation and wrong for production — browsers warn, and any client verifying the chain refuses.
Replace both files with a certificate for your real hostname, keeping the same filenames, then recreate the container:
If a reverse proxy on the VM terminates TLS instead, set APIP_AP_SERVER_HTTPS_ENABLED=false in api-platform.env and bind the portal to loopback only.
Terminating TLS in front of the portal is a legitimate production choice, but the hop from the terminator to the portal is then cleartext. Encrypt that hop too wherever it crosses a host or network boundary.
Authentication¶
Local authentication posts credentials to a Platform API, which validates them against a file-based user list. It exists for development and demos, and it is the portal's only outbound call to a Platform API — production deployments delegate login to an OIDC identity provider and make no such call at all, so accounts, password policy, and revocation live in your identity system:
api-portal-ui:
config:
auth:
idp:
issuer: https://idp.example.com/oauth2/token
authorizationUrl: https://idp.example.com/oauth2/authorize
tokenUrl: https://idp.example.com/oauth2/token
userInfoUrl: https://idp.example.com/oauth2/userinfo
jwksUrl: https://idp.example.com/oauth2/jwks
clientId: <portal-client-id>
callbackUrl: https://portal.example.com/api-portal/<org-handle>/callback
logoutUrl: https://idp.example.com/oidc/logout
scope: "openid profile email"
The chart renders auth.mode=idp once idp.clientId is set; the client secret comes from the Secret.
In configs/config.toml:
[api_portal.auth]
mode = "idp"
[api_portal.auth.idp]
issuer = "https://idp.example.com/oauth2/token"
authorization_url = "https://idp.example.com/oauth2/authorize"
token_url = "https://idp.example.com/oauth2/token"
user_info_url = "https://idp.example.com/oauth2/userinfo"
jwks_url = "https://idp.example.com/oauth2/jwks"
client_id = "<portal-client-id>"
callback_url = "https://portal.example.com/api-portal/<org-handle>/callback"
logout_url = "https://idp.example.com/oidc/logout"
scope = "openid profile email"
Keep the client secret out of this file — reference it from a mounted file instead, and put the file on every VM:
Both the callback and logout redirect URLs must sit under the portal's /api-portal mount and match what you registered in the identity provider.
The OIDC endpoints have no defaults on purpose, and the portal refuses to start in idp mode without issuer, authorizationUrl, tokenUrl, clientId, and callbackUrl. See Connect an identity provider.
Authorization¶
Authorization is separate from authentication and applies in both modes. Keep it on:
enabled: false makes any authenticated caller satisfy every Management API operation's scope list. It is a development opt-out and logs a startup warning.
In the default role mode the portal expands the token's roles claim through a grant table and ignores the token's own scope claim, so a caller can't widen a role's grant by requesting extra scopes. Mount your grant table rather than baking it into an image, so changing what a role may do is a restart rather than a rebuild. See Authentication.
Try-it proxy¶
The try-it console calls an API's registered endpoint server-side, through a same-origin proxy, so every gateway doesn't need CORS headers naming the portal. Two of its defaults are development-oriented and should be tightened in production:
[api_portal.tryout]
allow_http_endpoints = false # default true — production should allow only https://
allow_private_endpoints = false # default; leave false unless the gateway is genuinely private
tls_skip_verify = false # development only
timeout_ms = 15000
max_request_bytes = 1048576
max_response_bytes = 5242880
allow_private_endpoints is deny-by-default because the registered-endpoint allowlist can't protect against an endpoint registered to point at an internal service. Set it true only when the gateway legitimately sits on a private address — a cluster IP or in-cluster service name — and only after confirming which services the portal can reach.
Two protections hold regardless of these settings: the proxy only calls URLs contained by an endpoint registered for that API, so a caller can't choose an arbitrary host; and link-local and cloud-metadata addresses such as 169.254.169.254 are refused at connection time.
Upload ceilings¶
Uploads and archive extraction — theme ZIPs, API specifications, documents, landing-page content — are bounded by built-in defaults:
[api_portal.uploads]
max_bytes = 10485760 # 10 MiB per upload, or per entry inside an archive
max_total_bytes = 52428800 # 50 MiB total extracted size per archive
max_zip_entries = 500
max_depth = 10
max_total_bytes, max_zip_entries, and max_depth are what guard archive extraction against a decompression bomb. Raise them only deliberately, and only as far as a legitimate artifact needs.
Related¶
- Database Configuration: TLS to the database and the password Secret
- Control Plane Connection: securing the portal-to-Platform-API hop
- Configurations: every key referenced here