Skip to content

Security

Workspace access

Who may read, author, and run a workspace's workflows is governed by workspace access grants, layered on top of platform permissions. See Workspace Access.

CORS Configuration

The server restricts cross-origin requests to a whitelist of allowed origins. By default only the FRONTEND_URL is allowed.

Env var Default Description
ALLOWED_ORIGINS FRONTEND_URL value Comma-separated list of allowed origins

Origins can also be managed at runtime via Admin > Security Settings in the web UI (settings:manage permission required).

CSRF Protection

State-changing API requests authenticated with Bearer tokens skip browser CSRF because browser sessions and CSRF cookies are owned by the BFF RPs.

  • On login, the console BFF mints floh_console_csrf and the portal BFF mints floh_portal_csrf. The API does not mint browser CSRF cookies.
  • Each SPA reads its own CSRF cookie and sends it as the x-csrf-token header on every proxied HTTP method (the BFF asserts CSRF on GET as well as writes).
  • Mutating /api requests with Authorization: Bearer … skip API CSRF. The BFF enforces its own browser CSRF contract before proxying. API clients using Bearer tokens are not affected by API CSRF checks.

Channel authorization (azp)

Every IdP-issued access token is bound to a known OIDC client. The resource server checks user may (the token's scope claim) and client may (azp ∈ the local allow-set for that permission).

Client id Channel What it may call
floh-client Console BFF Any permission the user holds
floh-portal-client Public portal BFF Portal bundle only — no workflow:*
floh-mcp-client Dedicated MCP MCP_SCOPES only
floh-mcp-agent MCP agent (NHE) MCP_SCOPES only

Unknown or missing azp / string client_id is 403 (CHANNEL_DENIED). OIDC_CLIENT_ID and PORTAL_OIDC_CLIENT_ID alias to the console and portal channels when operators register custom Authifi client ids; other client ids stay unknown until the Authifi-cached map (LSA-9823). A Bearer value starting with floh_ is 401 in every environment; the Floh-issued API token is gone. Browser sessions are owned by the console and portal BFFs, not by the API. authenticate() sets request.user.permissions to the intersection of the verified access token's scope claim and the channel allow-set, so authenticate-only handlers cannot honor console-only permissions on a portal or dedicated MCP token. requirePermission / requireAnyPermission still re-check the allow-set. The allow-set is a typed constant in @floh/shared (AZP_CLIENTS_BY_PERMISSION); it is not loaded from Authifi in this release. A missing scope/scp on a portal or console token authenticates with an empty permission set — restriction is Authifi policy as 403. A missing claim on dedicated floh-mcp-client, or a non-string claim on any channel, is 401. Documented MCP uses floh-mcp-client; do not point MCP at floh-client or omitted scope follows the console open-RP rule. The API never falls back to a database lookup.

Agent delegation tokens (floh-mcp-agent)

floh-mcp-agent is a public Authifi client whose only tokens Floh accepts are NHE delegation tokens: an agent obtains one by device-code approval followed by RFC 8693 token exchange. The token's sub is the user who approved the agent, and act.sub names the agent (nhe:<id>).

  • A floh-mcp-agent token authenticates only when it carries org.labshare.nhe.delegation: true and an act.sub of the form nhe:<id>. Anything else is 401, decided before the revocation check and before any user row is created or updated.
  • Authifi puts a numeric client id in azp on these tokens, so the string client_id identifies the client.
  • The same markers on any other client's token are ignored; they grant nothing and do not change the channel.
  • A missing scope is 401, as for floh-mcp-client.
  • Audit entries keep the user as actor / actorId and record the agent as metadata.delegatedActor (nheId, and agentType when Authifi sends a known type). Metadata is outside the audit hash chain, so the agent field is not tamper-evident (#1543).
  • Step-up routes refuse a delegated caller with 403, whatever acr or auth_time the token carries — approving an agent is not a fresh sign-in. The body is the plain error shape; system_log records the refusal with errorCode DELEGATED_STEP_UP_DENIED. It is not a 401 challenge, because re-authorizing the agent cannot satisfy it.

Webhook Authentication

Connector webhook endpoints require HMAC-SHA256 signature verification.

  1. Configure a webhook secret on the connector (stored encrypted in the DB).
  2. The caller computes HMAC-SHA256(secret, request_body) and sends it as the X-Webhook-Signature header.
  3. The server verifies the signature before processing the event.

Requests without a valid signature receive a 401 Unauthorized response.

Rate Limiting

The server applies rate limiting via @fastify/rate-limit:

Scope Limit Window
Global 200 req 1 minute
/api/auth/* 20 req 1 minute
/api/entitlements/webhook/* 30 req 1 minute

Localhost (127.0.0.1, ::1) can be excluded from global rate limits in local development. When TRUST_PROXY=true, localhost allowlisting is disabled to prevent forwarded-header spoofing from bypassing per-IP throttling.

Set TRUST_PROXY=true only when your reverse proxy/load balancer is trusted and sanitizes X-Forwarded-* headers.

Session Security

Console and portal use distinct cookie pairs so a login in one app does not overwrite the other. Browser logins terminate at Authifi BFF sessions (floh_console_bff_sid / floh_portal_bff_sid); the API does not manage those cookies.

App Session cookie CSRF cookie
Console floh_console_bff_sid floh_console_csrf
Portal floh_portal_bff_sid floh_portal_csrf
Property Value
Cookie names See table above
httpOnly true (session); false (CSRF double-submit)
secure true for the browser-facing portal origin; Floh defaults the portal to HTTPS-first
sameSite lax
TTL 24 hours
Encryption Operators set CONSOLE_BFF_COOKIE_ENCRYPTION_SECRET / PORTAL_BFF_COOKIE_ENCRYPTION_SECRET; Compose maps each to that process's AUTH_COOKIE_ENCRYPTION_SECRET. The API's optional SESSION_ENCRYPTION_KEY remains separate
Storage Console and portal BFF sessions use explicit SESSION_STORAGE_TYPE=cookie or SESSION_STORAGE_TYPE=redis. The deploy Compose files, the console and root Compose files, and the host-Node launchers use Redis db 1 (portal-bff:) and db 2 (console-bff:); docker-compose.portal.yml and docker-compose.portal-dev.yml still default to cookie — see the exception below
Domain Host-only by default. Console may set optional COOKIE_DOMAIN; portal browser sessions remain host-only

The portal BFF supports both explicit Floh-documented session modes:

  • SESSION_STORAGE_TYPE=redis for every deployment Floh ships, and the default the deploy, console, and root Compose files and the host-Node launchers select
  • SESSION_STORAGE_TYPE=cookie for a lightweight single-instance deployment that never uses step-up

Two committed Compose paths are still the exception: docker-compose.portal.yml and docker-compose.portal-dev.yml default SESSION_STORAGE_TYPE to cookie, and FLOH_PORTAL_BFF_RUNTIME=docker selects the latter. Those paths carry the chunk-corruption failure described next, so set SESSION_STORAGE_TYPE=redis explicitly when running them anywhere step-up is exercised.

Cookie storage is not merely a scaling trade-off. The gateway's cookie store splits a large session across numbered chunks and does not clear the trailing ones when a session shrinks, so the next read concatenates stale bytes and decryption fails for every subsequent request — which is what a step-up callback does, because the elevated session no longer carries the pre-step-up resource tokens. Tracked upstream as LSA-10149.

Redis misconfiguration must fail closed; Floh does not silently fall back from a requested Redis mode to cookie sessions. Upstream Authifi also supports memory and MySQL session stores, but Floh's committed operator paths document only cookie and Redis.

What ends a BFF session

A console or portal BFF session ends when:

  • the user signs out through the app (/bff/logout, RP-initiated logout at Authifi, which also ends the shared SSO session);
  • the BFF session expires (24-hour TTL);
  • a refresh fails because Authifi has revoked the refresh token;
  • Authifi sends an OIDC back-channel logout for the session.

Back-channel logout is on for the deployed console and portal BFFs (auth.backchannelLogout through NODE_CONFIG in docker/docker-compose.deploy.yml). Authifi POSTs a signed logout token to https://<origin>/bff/backChannelLogout, the URI registered on floh-client and floh-portal-client. The BFF verifies the token against Authifi's published keys and rejects anything unsigned, signed by another key, or addressed to another client, then ends the matching session in Redis. Today Authifi sends one when the user signs out of another application on the same SSO session; administrator termination and concurrent-session eviction are tracked in LSA-10334. Ending the BFF session stops the browser's API calls; how the API treats an access token that was already issued is described in Token revocation.

It is deliberately off in the committed docker/bff/*.json: those files also serve the local launchers and the cookie-mode portal Compose files, the gateway refuses to start with back-channel logout and cookie sessions, and Authifi cannot reach a developer machine.

The API ignores X-Portal-Origin for authentication and RP selection. Portal browser logins terminate at the BFF. Cross-app SSO is an explicit account-menu item (Open Portal / Open Admin Console); the IdP session provides silent re-auth into the sibling browser session.

Step-Up Authentication

Floh enforces step-up (re-authenticated MFA) on a curated set of high-risk endpoints. The flow follows the Authifi step-up contract:

  1. The server refuses with a 401 carrying both an RFC 9470 WWW-Authenticate header and the legacy JSON body (see RFC 9470 challenge).
  2. stepUpInterceptor reads the challenge, preferring the header — it is the only source that carries max_age, and it is what the Authifi BFF keys off. The MFA_OR_AAL_2_REQUIRED body remains the fallback for responders that predate the header.
  3. By default a same-origin popup handles the challenge so the opener SPA never unmounts and in-progress form state is preserved. A PrimeNG dialog (StepUpDialogComponent, shared from @floh/web-shared) asks the user to click Verify — the click is a user gesture, which window.open requires to bypass popup blockers.
  4. Where the popup goes depends on who owns the OIDC transaction. Both SPAs forward the challenge's acr_values and max_age unchanged; substituting either would request the wrong strength or silently drop the recency constraint.
SPA Popup target Completion
Console (packages/web) /bff/login?popup=true&acr_values=…&max_age=… — the Authifi BFF is the relying party The BFF redirects to /auth/step-up-done on success and /auth/step-up-failed?error=<code> on failure
Portal (packages/portal-web) /bff/login?popup=true&acr_values=…&max_age=… — the Authifi BFF is the relying party The BFF redirects to /auth/step-up-done on success and /auth/step-up-failed?error=<code> on failure
  1. The landing page signals the opener over three same-origin paths (BroadcastChannel, postMessage, and a localStorage marker the opener also polls). No single path is reliable: after the popup returns from a cross-origin IdP, Chromium's COOP handling often severs window.opener and can partition BroadcastChannel.
  2. On success stepUpInterceptor retries the original request exactly once. On failure it rejects with the BFF's stable code and rethrows the original 401 — retrying would only earn a second denial.
  3. When popups are unavailable (blocked by the browser, disabled by admin via STEP_UP_POPUP_ENABLED=false, or the user chooses "Continue with full page"), the interceptor falls back to a full-page redirect through the same relying party, carrying return_to=<currentPath>.

For console and portal browser sessions, the Authifi BFF stores the replacement access token server-side and the API re-verifies its acr and auth_time on the proxied retry — the browser never sees token material.

Portal failure bridge

/auth/step-up-failed exists so a failed reauthorization can never be mistaken for a completed one. The BFF redirects there with one of five stable codes — reauthorization_cancelled, invalid_reauthorization_state, expired_reauthorization_state, unmet_authentication_requirements, or reauthorization_failed — and never with provider text, tokens, or raw OAuth state. The page maps the code to a fixed message; the raw value is never interpolated into the DOM, so an unrecognized or hand-crafted code degrades to the generic message rather than rendering attacker-controlled text.

Unlike the success bridge, this page does not auto-close: the user needs to read why verification failed.

Its path must match the BFF's configured popupFailurePath in docker/bff/portal.json / docker/bff/console.json exactly, and the corresponding Angular route must exist. Both are pinned by packages/portal-bff/test/config-invariants.test.ts and packages/portal-bff/test/console-config-invariants.test.ts.

Verification model

Every guarded route reaches the same decision point, enforceStepUp, which picks its evidence from how the caller authenticated:

Caller Evidence Outcome
Floh cookie session Session amr + mfaLoginTs Fresh MFA within the window proceeds.
IdP-issued JWT (including the portal via the Authifi BFF) Access-token acr + auth_time Both must satisfy the policy; otherwise denied.
Bearer value starting with floh_ — 401 in every environment. The Floh-issued API token is gone.
No authentication context — Denied.

For cookie sessions the guard trusts the amr claim once it has been recorded in the session, but rejects the call when the most recent step-up is older than the configured freshness window. Refresh-token rotation does not advance mfaLoginTs — a fresh interactive /callback is required.

For token callers the guard reads the access token's acr and auth_time. It never consults amr: amr names the methods used, not the strength reached, so it cannot distinguish multi-factor from phishing-resistant. An absent or empty acr satisfies nothing, even when auth_time is fresh — a password-only Authifi token carries acr: "". Refreshing a token preserves auth_time, so a refresh cannot turn a stale authentication into a fresh one.

Setting Default Override
STEP_UP_AUTH_WINDOW_SECONDS 300 Per-call (consent step, catalog workflow).
STEP_UP_POPUP_ENABLED true Set to false to force the legacy full-page redirect flow for every step-up challenge. Exposed to SPAs via GET /api/auth/config.stepUpPopupEnabled so the interceptor can bypass the popup entirely.
STEP_UP_ACR_ALIASES see below JSON object mapping each requestable acr_values string to the access-token acr values that satisfy it.
STEP_UP_CLOCK_TOLERANCE_SECONDS 60 Leeway applied to both sides of the auth_time freshness comparison. Capped at 300.

The ACR alias matrix

acr_values is what Floh asks the IdP for; acr is what the issued token asserts. They are different vocabularies, so the API needs a matrix saying which asserted values satisfy which request. The shipped default is the Authifi BFF recommended set plus the former console PAPE and InCommon indicators on mod-mf:

{
  "mod-mf": [
    "http://schemas.openid.net/policies/modrna/multi-factor",
    "http://schemas.openid.net/pape/policies/2007/06/multi-factor",
    "http://schemas.openid.net/pape/policies/2007/06/multi-factor-physical",
    "urn:mace:incommon:iap:silver",
    "http://idmanagement.gov/ns/assurance/aal/2",
    "http://idmanagement.gov/ns/assurance/aal/3",
    "http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true",
    "http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true&hspd12=true"
  ],
  "mod-pr": [
    "http://schemas.openid.net/policies/modrna/phishing-resistant",
    "http://idmanagement.gov/ns/assurance/aal/3",
    "http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true",
    "http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true&hspd12=true"
  ]
}

mod-pr deliberately omits the multi-factor URI: a phishing-resistant request must not be satisfiable by an OTP-grade token.

Matching is by exact string against this matrix, never by echoing the request value back. An IdP that reflects acr_values into acr without enforcing it cannot assert its own compliance. The console OIDC callback uses this same matrix when stamping MFA on a new cookie session.

A mod-mf challenge also accepts token values listed under mod-pr (phishing-resistant is a stricter form of multi-factor). A mod-pr challenge does not accept mod-mf-only values. Console cookie stamping uses that same rule, so an ACR configured only under mod-pr clears both cookie sessions and Bearer callers when the route requires mod-mf.

The matrix is validated at startup and fails closed. Malformed JSON, a non-object, an empty or missing entry for a requestable value, a non-string member, a key that would pollute the prototype, or any value containing characters that cannot appear in a WWW-Authenticate challenge all refuse startup rather than silently reverting to the default.

Keep this matrix identical to the Authifi BFF's bff.stepUp.acrAliases. A BFF that is more lenient completes a reauthentication the API then rejects; a BFF that is stricter refuses a token the API would have honored. Neither loops — the browser retries once — but both cost the user a wasted reauthentication.

This is enforced, not just documented: packages/server/test/unit/architecture/step-up-acr-alias-parity.test.ts compares DEFAULT_STEP_UP_ACR_ALIASES against the committed docker/bff/portal.json in both directions. Operators who override STEP_UP_ACR_ALIASES at runtime must make the matching BFF change themselves — the test can only cover the committed defaults.

RFC 9470 challenge

Alongside the existing JSON body, a step-up refusal now carries a standards-based challenge header:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="mod-mf", max_age="300"
Content-Type: application/json

{"statusCode":401,"error":"Unauthorized","message":"…","code":"MFA_OR_AAL_2_REQUIRED","windowSeconds":300}

The header is what the Authifi BFF reauthorization flow keys off. The body is unchanged, so existing SPA interceptors keep working without modification. The two must stay in sync: a client that sees one but not the other would either miss the challenge or reauthenticate at the wrong strength.

acr_values is the value that route actually enforces — mod-mf by default, or mod-pr on the high-impact routes listed below. A mismatch would send the browser to reauthenticate at a strength the API would still reject.

Diagnosing a denial

Every token-path denial logs a structured warning with reason, requiredAcr, the observedAcr (or null), and the route. reason is one of no_alias_configured, acr_missing, acr_not_accepted, auth_time_missing, auth_time_future, or auth_time_stale. An IdP whose claim profile does not match the configured matrix is otherwise indistinguishable from a user who simply has not re-authenticated. No token material is logged; acr is a policy identifier, not PII.

The popup flow depends on Cross-Origin-Opener-Policy: same-origin-allow-popups so the popup retains window.opener long enough to postMessage back. The server sets this explicitly in the @fastify/helmet registration; do not downgrade it to same-origin without also disabling STEP_UP_POPUP_ENABLED.

Guarded endpoints

  • PAM: GET /pam/sessions/:id/credential (credential reveal), POST/PUT/DELETE /pam/policies (policy mutations).
  • Auth: PUT /auth/platform-access-group-ids (platform-access group map mutation).
  • Connectors: POST /connectors, PUT /connectors/:id, POST /connectors/:id/test (when config is in the request body), POST /connectors/:id/provision-webhooks, and POST /connectors/rotate-keys.
  • Config transfer: POST /config-transfer/import.
  • Workflows (per-workflow opt-in): consent step (requireStepUpAuth) and catalog submission (catalogRequireStepUpAuth) — each may set its own stepUpWindowSeconds override.

Step-up guards routes that read or write secrets or that change authorization policy. Permanent deletion routes (DELETE /users/:id/permanent, DELETE /connectors/:id/permanent, DELETE /workflows/:id/permanent, DELETE /projects/:id/permanent, and DELETE /email-templates/:id/permanent) require their domain permission and console system:administer, without step-up. Foreign keys on workflow_run, approval, project, workflow_definition, role_assignment, and service_ticket, among other tables, reject DELETE /users/:id/permanent with 409 Conflict when a user has operational records. audit_log.actor_id has no foreign key to "user", and the trg_audit_immutable trigger on audit_log rejects updates and deletes. DELETE /users/:id/permanent deletes the user row, then writes previousState to audit_log. Earlier references to user:manage in step-up policy discussions applied to the per-user permission-override routes removed in LSA-9939.

Per-route required ACR

Default is mod-mf. High-impact routes require mod-pr. Per-route values must be in the allowlist (mod-mf, mod-pr) or route registration throws.

Route Required ACR
GET /pam/sessions/:id/credential mod-pr
POST /connectors/rotate-keys mod-pr
POST /config-transfer/import mod-pr
All other requireStepUp / enforceStepUp sites mod-mf

Bearer-token caller policy

Bearer is a transport, not an identity. Every API Bearer credential is an IdP-issued JWT. A value starting with floh_ is 401 before any parsing — there is no Floh-issued API token, and no environment branch re-enables one.

IdP-issued JWTs must prove step-up through acr and auth_time on every guarded route, including the admin infrastructure operations above (PAM credential reveal, PAM policy mutations, connector secret writes and key rotation, connector test with a config override, Twilio webhook provisioning, platform-access group mutation, config import) and the per-workflow opt-ins (consent step requireStepUpAuth, catalog catalogRequireStepUpAuth).

Breaking change

IdP-issued JWTs were previously exempt from requireStepUp(), so external API clients calling those admin routes with a JWT will now receive 401 MFA_OR_AAL_2_REQUIRED unless their token carries an accepted acr and a sufficiently recent auth_time.

Reauthenticate with the acr_values named in the challenge — mod-mf on most routes, mod-pr on the high-impact routes in the matrix above — so the IdP issues a token with the required claims. This is the intended path and is what the portal does automatically via the BFF.

To roll back, widen STEP_UP_ACR_ALIASES — but note this weakens the gate for every caller on the affected route, so prefer fixing the token's claim profile.

Token revocation

What an Authifi session termination revokes

Ending an Authifi SSO session — an administrator deleting it, the maxConcurrentSessions limit evicting it, or the user signing out everywhere — deletes the grant, access tokens, refresh tokens, and authorization codes of every application that signed in on that session, not only the first one. That holds from @authifi/auth 12.32.0 (idbroker#12354); earlier releases revoked only the first client's grant (LSA-10261).

Revocation at Authifi does not by itself stop a token Floh already holds:

  • Refresh tokens stop working at once, so the BFF's next refresh fails and its browser session ends. When Authifi also sends a back-channel logout, the BFF session ends immediately instead (see What ends a BFF session).
  • Access tokens are JWTs the API verifies locally. Without the check below, a revoked access token keeps working at the API until its own exp — up to one hour.
  • Tokens sent to Authifi itself, such as the console BFF's Authifi admin token, are rejected by Authifi on the next call.

How the API checks

With FLOH_TOKEN_REVOCATION_MODE set to observe or enforce, authenticate() asks Authifi whether a verified access token is still live before any write. It calls GET {Authifi API base}/auth/user/selfService/sessionDetails with the caller's own bearer token, so no additional Authifi client or secret is involved. The URL is built from OIDC_ISSUER only, and it must be https: with checks on, a non-https issuer stops the server at startup rather than send bearer tokens in the clear. A request asks Authifi at most once, even when several guards authenticate it.

Request Oldest status the API relies on Setting
GET, HEAD not checked not configurable
Every other method checked on the request FLOH_REVOCATION_WRITE_MAX_AGE_SECONDS (0–60)
Step-up-guarded routes checked on the request not configurable

Reads are not checked because every Authifi API call, sessionDetails included, counts as activity on the caller's Authifi session and resets its idle timer. The console and portal poll some pages in the background with GET requests, so checking reads would keep an unattended session alive until its maximum lifetime. Writes are user actions, so checking them extends the session only when the user is active. A revoked token can therefore still read until its exp, but cannot change anything.

Step-up routes are checked on the request even though step-up already demands a recent sign-in: step-up proves the user authenticated recently, not that the token is still live, so a session ended inside the step-up window would otherwise leave a working token on the most sensitive routes.

Results are cached in Redis, on a connection of their own, and shared by every API node. The cache key is a SHA-256 of the token's jti; the token and Authifi's response are never stored. A revoked result is kept until the token expires and is never replaced by a later "active" one.

Only Authifi's explicit revoked_token answer counts as revoked; it produces 401 Invalid token, which the console and portal handle as an expired session. Any other answer — a missing session, an unexpected status, a timeout, Redis being unreachable or not answering within 250 ms — counts as unavailable: the request proceeds on local verification alone and the API logs a warn with reason: "revocation_status_unavailable". A revocation already cached is still enforced during an outage. While the status is unavailable, an uncached revoked token works until its exp, so treat a sustained rate of that warning as an incident.

In observe mode the API makes the same checks and logs reason: "would_reject" in place of rejecting.

RFC 7662 introspection is not used: oidc-provider 9.x refuses to introspect JWT access tokens (400 unsupported_token_type), which is the only format Floh's resource servers issue.

OIDC Startup Guard

Dev auth bypass has been removed. The server refuses to start in all environments when required OIDC fields are missing:

  • OIDC_ISSUER
  • OIDC_CLIENT_ID

It also refuses to start when two channel audiences (FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, FLOH_MCP_AUDIENCE) resolve to the same value, or when one equals OIDC_ISSUER. Each one defaults to https://{console,portal,mcp}.floh.api when unset.

The channel audiences are the allow-list the API verifies each access token's aud claim against. FLOH_RESOURCE_ID (deprecated alias OIDC_AUDIENCE) is not part of that allow-list and is not a startup requirement; it names the API resource for operator tooling. Setting FLOH_RESOURCE_ID and OIDC_AUDIENCE to different values is a startup error.

Secret Management

The following secrets must be explicitly set in production. The server will refuse to start if any of them use their default/fallback values:

Secret Env var Default (dev only)
JWT secret JWT_SECRET dev-jwt-secret
Database password DB_PASSWORD floh_secret
Session secret SESSION_SECRET Same as JWT_SECRET
Connector encryption CONNECTOR_ENCRYPTION_KEY Insecure dev key
Session encryption SESSION_ENCRYPTION_KEY Unencrypted storage

Generate encryption keys with:

openssl rand -hex 32

Portal BFF deployments additionally keep PORTAL_OIDC_CLIENT_ID, PORTAL_OIDC_CLIENT_SECRET, and PORTAL_BFF_COOKIE_ENCRYPTION_SECRET in operator .env / secret injection. Compose maps them to the Authifi runtime's AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, and AUTH_COOKIE_ENCRYPTION_SECRET. Do not place those values in Angular config, committed JSON, or other browser-visible settings. When the BFF terminates TLS directly, SERVER_CERT and SERVER_KEY contain PEM contents, not file paths.

Error Handling

In production, API error responses contain only statusCode, error, and a generic message. Stack traces and internal details are never exposed. In development, stack traces are included when SHOW_ERROR_DETAILS=true.

Connector and sync-management routes now use a shared API error envelope helper so explicit route-level failures (for example 400/403/404/409 paths) follow the same { statusCode, error, message } shape as global error-handler responses.

Sensitive Logging Controls

Authentication integrations must not log session payloads, bearer tokens, or raw response bodies from identity providers. Auth-related logs should include only sanitized URLs and status metadata.

OIDC and MCP outbound identity-provider/API calls should enforce bounded timeouts (AbortSignal) so network hangs fail fast and are observable.

Permission snapshots loaded from persisted JSON (workflow_run.accepted_permissions and scheduled_trigger.accepted_permissions) are validated as string arrays before use. Malformed payloads fail closed to an empty set.

Production Checklist

  • [ ] NODE_ENV=production
  • [ ] OIDC_ISSUER is set
  • [ ] JWT_SECRET is a strong random value
  • [ ] DB_PASSWORD is not the default
  • [ ] SESSION_SECRET is a strong random value
  • [ ] CONNECTOR_ENCRYPTION_KEY is a 64-char hex key
  • [ ] SESSION_ENCRYPTION_KEY is a 64-char hex key
  • [ ] AUDIT_CHECKPOINT_KEY is a 64-char hex key
  • [ ] ALLOWED_ORIGINS is restricted to known frontend URLs
  • [ ] SHOW_ERROR_DETAILS=false
  • [ ] Rate limiting is enabled