Skip to main content

AxonFlow v9.17.0 Release Notes

v9.17.0 gives the customer portal a real self-service password recovery flow. A user who has forgotten their organization password can request a reset link by email, through your own SMTP relay or through Resend, and complete the reset in the portal UI. Until now the forgot-password endpoint answered 501 Not Implemented on every deployment, and the only way back in was an operator running the admin reset endpoint.

The release also hardens that endpoint. Several of the changes below are security fixes to behavior that predates the email flow.

Alongside it, segment-scoped policy targeting completes the rollout that began in v9.14.0: the orchestrator's dynamic policies can now target a governance segment, and the remaining agent policy planes honour the same gate.

It is a MINOR by the semver policy: it adds backward-compatible capabilities that stay dormant until you configure or author something, and removes nothing an operator was meant to rely on. One additive migration runs (core/159); see "Before you upgrade".

Am I affected?

Deployments that want users to recover their own portal password get the new flow. It is opt-in: configure an email transport and it turns on. Configure nothing and the endpoint keeps returning 501 exactly as before.

Self-hosted and in-VPC deployments are the reason the SMTP transport exists. You can point password reset at the mail relay you already run, with no third-party email vendor and no sending-domain verification anywhere outside your network.

Anyone whose portal sits behind more than one proxy layer should read the AXONFLOW_TRUST_PROXY_HOPS section: the rate-limit fix below makes the client-address calculation strict, and a two-layer ingress needs to declare itself.

Anyone who scripted against the forgot-password endpoint returning a reset token on a staging stack must change that script. See "Before you upgrade".

Every deployment runs the one additive migration. If you adopted segment-scoped policies at v9.14.0, this release completes their rollout: dynamic policies on the orchestrator, and the fleet, gateway and OpenAI-compatible agent planes, now honour segment targeting too. Nothing about policy evaluation changes until someone authors a segment-scoped policy: a policy with no segment keeps matching every caller in its tenant, exactly as before.

Everyone else: routine upgrade. Apply the migration, and nothing else changes until you configure an email transport.

Self-service password reset by email

With an email transport configured, POST /api/v1/auth/forgot-password emails a reset link to the organization's contact address instead of refusing:

  1. the user submits their organization ID on the portal's /forgot-password page
  2. the portal answers with the same generic confirmation in every case, so the response never reveals whether an organization exists
  3. if the organization exists and has a contact email, a link is sent to that address; it expires after 1 hour and there is one active token per organization
  4. the link opens /reset-password, where the user sets a new password; the token is single-use

The endpoint is rate-limited to 5 requests per minute per IP address, matching the login endpoint.

Two transports, and why SMTP is the important one

TransportConfigure withSuits
SMTP relayAXONFLOW_SMTP_HOST + AXONFLOW_SMTP_PORTSelf-hosted and in-VPC deployments. Reset mail goes through a relay you already run and never leaves your infrastructure.
ResendRESEND_API_KEYDeployments already using Resend, with a sending domain verified there.

Resend is an HTTPS service: using it means signing up with a third party and verifying a sending domain there, which many regulated deployments cannot do. The SMTP transport removes that constraint, so the recovery flow is now genuinely available to a self-hosted deployment and not only to an AxonFlow-hosted portal. It uses the Go standard library only, with no new dependency.

Both transports share two settings: AXONFLOW_FROM_EMAIL and AXONFLOW_PORTAL_BASE_URL. Optional SMTP settings cover authentication (AXONFLOW_SMTP_USERNAME and AXONFLOW_SMTP_PASSWORD, set both or neither), transport security (AXONFLOW_SMTP_TLS, one of starttls, tls, or none, defaulting to STARTTLS), and a private-CA bundle (AXONFLOW_SMTP_CA_FILE).

If both transports are configured, Resend wins and the SMTP settings are ignored. The portal logs this loudly at startup, and the CloudFormation template rejects a stack that sets both. The selected transport is named in the startup log.

Configuration is fail-closed. Nothing configured keeps the honest 501. A transport that is only partly configured, for example a port with no host, an unrecognized TLS mode, or a username with no password, makes the portal refuse to build the sender and name the offending variable at startup, rather than quietly presenting itself as "email disabled". A deployment can never report "link sent" without sending one.

Full setup reference, including the CloudFormation parameter names: Enterprise Authentication Surfaces.

Portal UI recovery pages

The portal login page now has a "Forgot password?" link. /forgot-password always shows the same generic confirmation, and shows an honest "not configured for this deployment" notice rather than a fake confirmation when the deployment has no email transport. /reset-password consumes the emailed token and gives clear errors for an expired or already-used link.

Per-organization re-issue cooldown

Independently of the per-IP rate limits, an organization that already has an unused, unexpired reset link issued in the last 5 minutes gets no second email from a repeat request: no new token is minted, the outstanding link is not replaced, and the response is the same generic confirmation. The link already in the user's inbox keeps working.

Both rate limiters key on IP address, but an organization ID is the portal login username and not a secret. Without the cooldown, a caller rotating source addresses could repeatedly replace an organization's reset token and kill a legitimate user's just-delivered link before they could click it. A used or expired token never blocks a new request.

Segment targeting reaches the rest of the policy planes

A policy can target a governance segment, a population within a tenant resolved from the caller's SCIM group membership, so a rule that should apply to contractors or to one business unit does not have to apply to everyone in the tenant.

That capability arrived in v9.14.0, for static policies on the agent's governed request plane only. Those release notes named what was deliberately left out of scope. v9.17.0 closes that list. If you adopted segment-scoped policies at 9.14.0, this is the rest of the rollout, not a new mechanism to learn.

What is new here:

  • Dynamic policies on the orchestrator can now carry a segment, and the orchestrator evaluates such a policy only for callers resolved into that segment. The dynamic policy engine was the largest of the planes named as not yet segment-aware in 9.14.0. This part is Enterprise and is what the core/159 migration is for.
  • The remaining agent planes honour the same gate. The fleet / MCP-server plane, the gateway check plane and the OpenAI-compatible plane now apply segment scoping through the shared policy engine, so a segment-scoped policy is enforced consistently wherever a caller's identity is validated, rather than on the governed request plane alone.

Static-plane targeting itself is unchanged from 9.14.0.

Nothing changes until you author a segment-scoped policy

The scoping is strictly additive. A policy with no segment keeps matching every caller in its tenant, exactly as it did before, and nothing writes a segment onto an existing policy. Upgrading therefore changes no policy decision anywhere. Segment targeting takes effect only once an operator authors a scoped policy.

The agent-side gate compiles into Community builds as well, where it is inert: the Community edition has no segment resolver, so no caller resolves into a segment and every policy is evaluated tenant-wide as before. The column exists on both editions so the schema does not fork.

The dynamic-policy half requires the segment_id column added by core/159; see "Before you upgrade".

Security fixes

No reset token is returned over HTTP on a deployed stack

The forgot-password endpoint used to return the reset token in the response body when ENVIRONMENT was staging or unset. A staging stack is a deployed, reachable service, and on that branch the endpoint handed a live, password-changing token for any organization to any anonymous caller, including organizations with no contact email at all.

That path is now limited to a developer machine with no email backend: ENVIRONMENT unset, development, dev, or local. Every deployed environment returns the honest 501, or sends the link once email is configured.

Reset tokens are hashed at rest

The reset-token table now stores a SHA-256 digest rather than the token itself, so read access to that table is no longer equivalent to organization takeover. The column itself is unchanged, so this fix carries no schema change of its own; only what is written into it changed.

The rate-limit bucket key is no longer attacker-controlled

The portal's rate limiter keyed its bucket on the entire X-Forwarded-For header. Because a caller can prepend anything to that header, rotating a forged prefix put every request in a fresh bucket and the limiter never fired. It now keys on the rightmost hop, which is the address your load balancer (or an nginx using proxy_add_x_forwarded_for) actually observed. This restores the login endpoint's documented limit of 5 attempts per minute.

Where the portal is directly reachable, the header remains unverifiable however it is parsed. Put the portal behind an ingress that sets the header.

The marketplace template defaults to a production posture

The AWS Marketplace CloudFormation template now defaults EnvironmentType to production instead of staging. That default is what made the token disclosure above reachable on a stock deployment, and a non-production posture is the wrong default for a template whose primary use is a real deployment.

A new default-parameter stack consequently requires X-Admin-API-Key on /api/v1/admin/*. The template already provisions and injects that key, so nothing further is needed.

New stacks only, and an existing stack keeps its current value

CloudFormation stores a stack's resolved parameter values, so an existing stack keeps whatever EnvironmentType it was created with across updates. Changing the default does not change a deployed stack. If your stack is still on staging, set the parameter explicitly, which you should do regardless. The allowed values are unchanged, so a stack still holding staging continues to update cleanly.

AXONFLOW_TRUST_PROXY_HOPS for multi-proxy deployments

The rate-limit bucket key is the Nth X-Forwarded-For entry counting from the right, where N is AXONFLOW_TRUST_PROXY_HOPS.

The default is 1, which is exactly the behavior that ships without it, so no deployment changes by upgrading and you can adopt the setting whenever you need it. Set it to the number of trusted proxy layers when the portal sits behind more than one, for example a CDN in front of your own nginx. Otherwise every user shares a single 5-per-minute login bucket and five failed sign-ins from anyone lock out the whole organization.

Set it no higher than the real number of proxies: a larger value counts on client-supplied text. The resolved value is logged once at startup, and it is exposed as the TrustedProxyHops CloudFormation parameter and a Compose passthrough.

SSO_BASE_URL is now AXONFLOW_PORTAL_BASE_URL

One value builds the emailed reset link and the SAML ACS and OIDC redirect URLs. Naming it SSO_BASE_URL led deployments that use password login and no SSO to skip it, after which reset mail either refused to start or pointed at the wrong host.

AXONFLOW_PORTAL_BASE_URL is the name to use from this release. SSO_BASE_URL keeps working as a deprecated alias, logged once at startup, and the new name wins when both carry a usable value. This is not a breaking change: a deployment that sets only SSO_BASE_URL behaves exactly as before, and the CloudFormation template renders both names from the single PortalExternalBaseURL parameter, so a stack update that lands before an image update cannot strip the value.

Both names are normalized before precedence is decided, so a value that normalizes to nothing, such as / or whitespace, can never shadow a working value under the other name.

Relatedly, the portal's SAML and OIDC services used to read SSO_BASE_URL directly rather than through the shared resolver that builds reset links. Both now resolve through the one resolver, so setting only the new name gives correct reset links and correct SAML assertions.

Before you upgrade

  • One additive migration: core/159. It adds a nullable segment_id column to the dynamic-policy table plus a partial index, using ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS, so it is idempotent and safe to re-run. No data is rewritten and no existing row changes meaning: a policy with no segment_id is unaffected by segment targeting and keeps matching exactly as before. Apply it as part of the upgrade.
  • Reset links issued before the upgrade stop working. Reset tokens are now hashed at rest, so a token minted by the previous version no longer matches. Anyone holding an unused link must request a new one. These links live at most 1 hour, so the window is short, but if you have just told a user to check their inbox, tell them to request again after the upgrade.
  • If you scripted against the token-in-body response on a staging stack, that path is gone. Use ENVIRONMENT=development on a developer machine, or the admin reset endpoint POST /api/v1/admin/organizations/{org_id}/password.
  • If your portal sits behind two or more proxy layers, set AXONFLOW_TRUST_PROXY_HOPS to the number of trusted layers. One layer needs no change.
  • If you want password recovery, configure a transport (AXONFLOW_SMTP_HOST plus AXONFLOW_SMTP_PORT, or RESEND_API_KEY) together with AXONFLOW_FROM_EMAIL and AXONFLOW_PORTAL_BASE_URL. A half-configured transport keeps the endpoint on 501 and names the missing variable in the startup log. See Enterprise Authentication Surfaces.
  • Existing CloudFormation stacks keep their EnvironmentType. The new production default applies to new stacks only. Set the parameter explicitly on an existing stack.
  • From older versions: 9.17.0 is cumulative. If you are crossing the 9.13.0 boundary from 9.12.x, the usual care applies: run the bundle's preflight.sh first and take a database backup. See the v9.12 to v9.13 upgrade guide.

SDK and plugin compatibility

No SDK or plugin changes are required. Clients compatible with v9.16.x are compatible with v9.17.0.