Changelog for Ory Polis OEL
v26.4.2
A deactivated directory refuses SCIM requests
A directory sync directory that was deactivated answered every SCIM request with 200 and an empty body, and ignored it, so the
IdP believed its sync worked while none of its changes were kept. It now answers with a 403 SCIM error,
Directory is deactivated, so the IdP reports the sync as failing. The secret is checked first: a request with a wrong secret is
still answered 401, so only the IdP that holds it learns the directory is deactivated. The Google Workspace sync no longer syncs
a deactivated directory.
See Directory Sync API.
Breaking changes
An IdP that still provisions into a deactivated directory now gets a 403 for every request, and reports provisioning errors,
where it got 200 before. If you deactivated a directory to pause it quietly, turn off provisioning in the IdP as well, or
activate the directory again.
Microsoft Entra ID retries the refused changes, and quarantines the provisioning job once enough of them fail; it catches up on its own if the directory is activated again within four weeks, but disables the job after four weeks in quarantine, after which an admin has to restart provisioning in Entra. Okta keeps them as failed tasks, to retry from its Tasks page.
Google Workspace directory authorization now requires a per-directory token
Starting the Google consent flow for a Google Workspace directory installs credentials on that directory. Until now the authorize
endpoint accepted any request that named a directory id. It now also requires the directory's google_authorization_token, a new
secret that every Google directory holds.
The google_authorization_url returned for a directory, and shown in the Admin Portal and on setup links, is now a complete link
that carries the directory id and the token. Copy and share it as it is. Directories created before this release receive a token
the first time they are read.
Breaking changes
- Authorization URLs built by hand from
google_authorization_urlplus adirectoryIdquery parameter no longer work. Use the URL exactly as the API or the Admin Portal returns it. generateAuthorizationUrlin the@boxyhq/saml-jacksonpackage now takes atokenalongsidedirectoryId.- Completing the consent screen replaces the directory's token, and so does setting
google_access_tokenorgoogle_refresh_tokenthrough the directory API. An authorization URL shared earlier stops working once the directory has been authorized; read the directory again to get the current one.
Polis metrics carry an outcome and a duration, and cover directory sync
Every Polis operation now reports one counter and one duration histogram, in seconds, with the same attributes. The counter
carries outcome, either success or error, so an error ratio can be computed per protocol and login type, and an attribute
that could not be determined before a request failed is reported as unknown instead of being left out.
Directory sync is now covered: dsync.request records every SCIM request by resource, method and response status, and
webhook.delivery records every webhook delivery with its response status or transport error, with event_type telling directory
sync's user and group events from the connection lifecycle events.
Every point carries app, which is polis, and version, as the Ory Go services' metrics do, and the duration histograms use
the same latency buckets as those services, so one dashboard query fits both.
The database pool is reported through the OpenTelemetry semantic conventions, db.client.connection.count by state,
db.client.connection.max and db.client.connection.pending_requests, read at collection time rather than every 30 seconds.
See Observability for Enterprise SSO and Observability for Directory Sync for the full list.
Breaking changes
Polis metrics no longer carry a service prefix: jackson.oauth.authorize is now oauth.authorize, and so on, with the service
named by the app attribute, as on the Ory Go services. The meter is named polis. Update dashboards and alerts to the new
names, for example oauth_authorize_total{app="polis"} in place of jackson_oauth_authorize_total. One counter is also renamed:
jackson.dsync.events_batch.failed is now dsync.event_batch.failed, in the singular as the other directory sync event metrics
are. The documentation had for some time listed the metrics under a polis. prefix that Polis never emitted; that prefix is gone
too.
The .error counters are gone. Their values are the outcome="error" series of the request counter of the same name:
jackson.oauth.authorize.error becomes oauth.authorize{outcome="error"}, and so on. The request counters now carry protocol,
login_type and outcome; the sum over outcome equals the previous total. Update dashboards and alerts that reference the
.error names or that summed the request counters without grouping by outcome.
The database pool gauges are replaced by the OpenTelemetry ones: jackson.db.connections.max is db.client.connection.max,
jackson.db.connections.waiting is db.client.connection.pending_requests, and jackson.db.connections.total and .idle are
both db.client.connection.count, split by db.client.connection.state. The old .idle is the
db_client_connection_state="idle" series; the old .total counted idle and in-use connections together, so it is the sum over
both states, sum(db_client_connection_count) rather than the db_client_connection_state="used" series alone. The db_name
attribute is now db.client.connection.pool.name.
Reject userinfo access tokens sent in the URL
The userinfo endpoint accepts the access token either in the Authorization header or in an access_token query parameter. A
token in the URL ends up in browser history, proxy and access logs, monitoring pipelines and referrer headers, which is why RFC
6750 discourages the query form and RFC 9700 forbids clients from using it.
Set OPENID_USERINFO_REJECT_QUERY_TOKEN=true to accept the token only from the Authorization header. Requests that carry it in
the URL then fail with a 400 invalid_request error instead of returning the profile. The default stays false, so nothing
changes until you enable it — turn it on once you have confirmed your clients send the token in the header.
Error responses from the endpoint now also carry the WWW-Authenticate challenge and the error and error_description fields
that OAuth defines, alongside the message field they returned before.
What you must do
A token the endpoint cannot accept — malformed, unknown, expired, or one it cannot decrypt — now returns 401 with
error: "invalid_token". It returned 403 before, and a token it could not decrypt returned 500. Check any client or monitor
that treats the userinfo response by status code: OpenID Connect clients that follow the specification already handle 401, but
code written against the old 403 needs updating. The message field keeps its previous value, so anything reading the body is
unaffected.
SCIM PUT /Groups/{id} now applies group members
A SCIM PUT on a group replaces its membership with the members in the request, and an empty members list clears it. Until
now PUT only renamed the group, so identity providers that push memberships with PUT, such as Okta apps created with the App
Integration Wizard, left groups empty. A PUT without members keeps the current membership. Each member added or removed sends
a group.user_added or group.user_removed webhook event, as PATCH does.
Upgrading
The first PUT that Okta sends for a group after the upgrade adds every member of that group, one at a time, within that request,
and sends a group.user_added event for each. For a group with thousands of members, the request can take longer than Okta waits,
and Okta reports the push as failed. The members added before then are kept, and the next push of that group adds only those still
missing, so retry the push from the Okta app's Push Groups tab until it succeeds. Expect a burst of group.user_added events
at your webhook endpoint while groups fill. To spread the load, push large groups one at a time.
Breaking changes
group.updated events now always carry raw.members: []. Before, a PUT that included members stored that list in the group's
attributes, so group.updated and GET /Groups repeated it back even though no member was added. Track membership with the
group.user_added and group.user_removed events instead, or read it from the paginated
group members endpoint,
GET /api/v1/dsync/groups/{groupId}/members. A SCIM GET /Groups/{id} lists members only with includeMembers=true, and only
for groups within DSYNC_MAX_INLINE_GROUP_MEMBERS, 500 by default.
SSO connection webhooks carry the client secret on sso.created only
Every SSO connection webhook, sso.created, sso.activated, sso.deactivated and sso.deleted, carried the connection's
clientSecret. The secret is a live credential: it authenticates the token exchange for the connection and authorizes updating or
deleting it. It is now sent on sso.created only, where an application first learns of a connection, for example one a customer
created through a setup link. The other events name the connection by its clientID.
See Webhook events.
Breaking changes
sso.activated, sso.deactivated and sso.deleted no longer include data.clientSecret. An application that reads it from
those events should keep the secret from sso.created, or fetch the connection through the API.
Batched directory sync webhooks reach DynamoDB-backed endpoints in order
With webhook batching (DSYNC_WEBHOOK_BATCH_SIZE) on DynamoDB, which lists the queue by key rather than by when events were
queued, a directory's events could reach its webhook out of order. Queued events now get IDs that sort in the order they were
queued, so DynamoDB reads the queue oldest first, as the other databases do. Events already queued before the upgrade keep their
old IDs and may be delivered out of order once.
Choose the SAML connection for single logout
/api/logout accepts a clientID parameter that names the SAML connection the user signed in through. You can send it with
tenant and product, or on its own.
When a tenant and product have several connections, Polis now picks the connection to log out through as follows:
- OIDC connections are skipped.
- If exactly one SAML connection supports single logout, Polis uses it.
- If more than one supports single logout, Polis responds with
400 Bad Requestuntil you passclientID. Before, Polis used whichever connection it read first, which could send the logout to an identity provider the user never signed in with.
Deleting a directory deletes its queued webhook events
With webhook batching (DSYNC_WEBHOOK_BATCH_SIZE), deleting a directory removed its users, groups and webhook event logs but left
the events still queued for its webhook, until the next batch run found the directory gone and deleted them. They are now deleted
with the directory. Each is recorded as a polis.dsync.webhook.dropped warning with the reason directory_deleted and its event
ID, as dropped events are, so a change that will never reach the webhook can still be told apart.
Directory sync webhooks accept any 2xx response and retry on a schedule
When directory sync events are batched, only a 200 response counted as a delivery. An endpoint that answered 201, 202, or
204 was treated as failing: its events stayed queued and the same batch was posted to it again and again. Any 2xx response now
counts as a delivery, and webhook event logs mark those deliveries as delivered.
A batch that does fail is now retried on a schedule rather than on every pass of the queue. The delay starts at 30 seconds,
doubles with each attempt, carries jitter, and settles at one hour between attempts. Set DSYNC_WEBHOOK_BATCH_RETRY_INTERVAL to
change the starting delay. Events that are waiting for their next attempt are skipped, so one connection whose endpoint is
unreachable no longer holds up the events of your other connections.
A failed batch is also one request to your endpoint now rather than four in quick succession. The HTTP client used to retry three more times on its own, three seconds apart, before the queue heard about the failure -- attempts that neither waited for the delay above nor stopped at a response the queue treats as final.
Within a connection, events are still delivered in the order they were queued: an event that is waiting to be retried holds back the events queued behind it for that connection, and only that connection. That ordering relies on the database returning queued events in the order they were written. Every engine does except DynamoDB, where the queue is keyed by a random id, so events for one connection can reach your endpoint out of order there.
An endpoint that answers 400 or 422 has read the payload and will not take it, so those events are moved to a dead-letter
store straight away rather than retried, and the events queued behind them carry on. Every other response, including 401, 403,
404, 413, 429, and anything 5xx, is retried. If an endpoint answers 413 because the batch is too large for it, lower
DSYNC_WEBHOOK_BATCH_SIZE and the queued events go out in smaller requests.
Events are still kept until they are delivered. If you would rather drop events that cannot be delivered, set
DSYNC_WEBHOOK_BATCH_MAX_RETRIES to the number of retries you want; an event that uses up that budget moves to the dead-letter
store, where it is kept for inspection and is never sent again.
No action is required.
Group members can be paged past the first page on DynamoDB
GET /api/v1/dsync/groups/{groupId}/members now returns the pageToken for the next page. On DynamoDB, which pages by token, a
group's members could only be read up to the first page.
Identity Federation logout finds the upstream connection from the SessionIndex
SAML responses from an Identity Federation app now carry a SessionIndex that names the upstream connection the user signed in
through. When the service provider sends that SessionIndex back in its LogoutRequest, as SAML single logout expects, Polis ends
the session at that connection. The service provider no longer needs to send idp_hint or the id_token when the app has more
than one upstream connection.
Sign-ins made before this release carry the old SessionIndex, so logging them out still needs idp_hint or the id_token.
Identity Federation logout works with more than one upstream connection
When an Identity Federation app has more than one upstream connection, single logout no longer sends the user to the identity provider selection page, which could not complete a logout. Polis now ends the session at:
- the connection named by
idp_hint, if set. Both bindings accept it in the query string, and HTTP-POST also accepts it as a form field; - otherwise, the app's only active OIDC connection;
- otherwise, the OIDC connection that issued the
id_tokenin the LogoutRequest'sExtensions.
If none of these settles it, Polis rejects the logout request. Include the id_token in the LogoutRequest to avoid this.
Polis exports traces through OpenTelemetry
Polis now exports traces beside its metrics. Set OTEL_EXPORTER_OTLP_ENDPOINT, or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, and each
request to Polis opens a request span named after the route, continuing a trace that arrives in a traceparent header, with a
span for each Polis operation (oauth.authorize and so on) carrying the attributes its metric carries, outcome included, so a
failing or slow series can be followed to the traces behind it. A failure sets the span status and records the error. The calls
Polis makes to identity providers and webhooks, and its database queries, are client spans, and outgoing requests carry the trace
on. Exported spans carry no request target, and the URLs of the calls Polis makes keep only their scheme and host, with the path,
credentials and query values replaced by REDACTED, so tokens, authorization codes and state values do not reach the trace
backend. Spans are reported by the Ory Polis service, as the other Ory services name their tracers; metrics keep polis as
their service name, so their Prometheus job label is unchanged.
The exporter is configured the way the metrics exporter is, with the traces-specific variables taking precedence;
OTEL_TRACES_EXPORTER=none turns traces off while the endpoint keeps serving metrics. The sampler decides which of those traces
are recorded and exported: it follows OTEL_TRACES_SAMPLER, parent-based always-on by default, so set a ratio on a busy
deployment.
A deployment that already sets OTEL_EXPORTER_OTLP_ENDPOINT for metrics starts exporting a trace for every request when it
upgrades, to the same collector. Make sure the collector accepts traces, or set OTEL_TRACES_EXPORTER=none to keep them off. With
a valid POLIS_LICENSE_KEY, the same endpoint also turns on logs; set OTEL_LOGS_EXPORTER=none to keep those off.
See Observability for Enterprise SSO and the environment variables.
Polis keeps syncing Google Workspace directories when one fails
A Google Workspace sync run stopped at the first directory that failed, such as one whose Google grant was revoked, and the
directories after it were not synced until a later run reached them, if one did. Each directory is now synced on its own: a
failure is logged, naming the directory, and recorded as a failed polis.dsync.sync, and the run goes on to the next directory.
The run also logs how many directories failed. A sync stopped during shutdown no longer begins another directory.
Polis logs every change to SSO connections and directories through OpenTelemetry
Every change to an SSO connection or a directory is now an OpenTelemetry log record, named after the webhook event:
polis.connection.sso.created, .activated, .deactivated and .deleted, and the same for polis.connection.dsync. Updates,
which have no webhook event, are polis.connection.sso.updated and polis.connection.dsync.updated, with the names of the fields
they changed, never their values. A change is logged whether or not a webhook is configured, and connection.event counts them by
event type.
A connection's records carry its tenant, product, client ID, name, protocol, whether it is active, and the IdP it trusts, by
provider and issuer; a directory's carry its ID, name and type, whether it is active, and whether it has a webhook. They never
carry a secret or a webhook URL. The latest record of each connection therefore answers which connections a tenant has, with which
IdP, and whether they are on. Like the other Polis logs, these need a valid POLIS_LICENSE_KEY.
Each change, and each run of the directory sync webhook batch job, is also a span, connection.sso.created or dsync.event_batch
say, so its records and webhook deliveries share a trace ID even where no request span is around them.
The span and log record of a lifecycle webhook delivery, sent to WEBHOOK_URL and counted as the event_type="connection" series
of webhook.delivery, now name the event and the connection or directory it was about.
A lifecycle webhook that fails no longer fails the API request that made the change. The change was already saved, but the request returned an error, so the caller could not tell that it had succeeded. The request now succeeds, and the failure is logged and recorded as a failed delivery. The webhook is not sent again after the transport's own retries.
See Connection changes.
Polis logs every directory sync change through OpenTelemetry
Every change directory sync makes to a user, a group or a group's members is now an OpenTelemetry log record, named after the
event the webhook receives, such as polis.dsync.user.created or polis.dsync.group.user_added. A change is logged whether or
not the directory has a webhook. Like the sign-in logs, these need a valid POLIS_LICENSE_KEY. Each record carries the directory,
with its tenant, product, ID, name and type, and the user or group it changed: the user's ID, email address, user name, full name
and whether they are active, and the group's ID and name. A change carries the trace of the SCIM request that made it, so a record
leads to that request and its outcome.
The records also carry the SCIM request body the IdP sent and the event as the webhook receives it. Set
DSYNC_TELEMETRY_REDACT=true to leave these out; the IDs, names and email addresses are logged either way. The Helm chart redacts
them by default, through monitoring.dsync.redact.enabled.
SCIM requests now say which directory they were for and, when they fail, why: the error's scimType, such as uniqueness, or its
status code, as error.type, which failed requests also carry on their metrics. A request to a deactivated directory, which Polis
accepts and ignores, is marked as such. Each Google Workspace sync of a directory is recorded as dsync.sync, with its outcome
and duration, and leads to the changes it made. dsync.event counts the changes by event type.
See Observability for Directory Sync.
Polis logs every sign-in and sign-out through OpenTelemetry
Polis now exports OpenTelemetry logs beside its metrics and traces, as an Enterprise feature: logs are exported only with a valid
POLIS_LICENSE_KEY, while metrics and traces need none. Every operation emits one log record when it ends, successful or not,
with the attributes of its metric and span and the trace and span ID of its request. Log records are not sampled, so every sign-in
is recorded even when only a share of the traces is kept.
The records of one sign-in share a polis.login.id across the authorize request, the IdP response, the token exchange and the
userinfo request. They carry the tenant, product, connection, IdP and the origin of the redirect URL, and from the IdP response
on, the user's ID and email address. A failure carries error.type, which says why it failed, and polis.sso_trace.id, the ID
the Admin Portal shows and the end user sees in the error. Records also carry the SAML request and response, the profile Polis
extracted and the OIDC ID token unless SSO_TELEMETRY_REDACT=true, which the Helm chart sets by default; a deployment that sets
its own environment exports them until it sets the variable. The user's ID and email address are logged either way. The OIDC
access and refresh tokens never are, not even in the profile of an identity federation app that includes them in its assertion.
Single logout is now instrumented as well, with the oauth.logout, oauth.logout.response and idfed.logout.response metrics
and spans, log records of the same names with a polis. prefix, and a polis.logout.id that ties a logout's request and response
together.
Logs are exported when an OTLP endpoint is configured for them, with OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or the general
OTEL_EXPORTER_OTLP_ENDPOINT, and batched as the OTEL_BLRP_* variables configure. A licensed deployment that already sets the
general endpoint therefore starts exporting logs; set OTEL_LOGS_EXPORTER=none to keep them off. The Helm chart has
monitoring.logs.otlpEndpoint and monitoring.logs.otlpProtocol.
Failed single sign-on and logout operations also carry error.type on their metrics, so an error series splits by cause. The
success series are unchanged.
See Logs and the environment variables.
Polis reports HTTP requests the way the Ory Go services do
Each request that matches a Polis route is now recorded as http_requests_total and http_requests_duration_seconds with the
code, method, endpoint and app attributes the Ory Go services carry on theirs, and the same latency buckets, so Polis
appears on the dashboards and alerts that read one request metric from every service. endpoint is the route template with path
parameters written {param}.
The metrics are measured on the request span, which Polis now opens whenever metrics are exported, even without a traces endpoint. Sampling traces does not sample them. See Observability for Enterprise SSO.
Polis traces every directory sync event to its webhook through OpenTelemetry
Each directory sync change now has an event ID, polis.dsync.event.id, on its log record and on every record of its webhook
delivery, so a change can be followed to the webhook and back. The ID is never sent to the webhook. An application that embeds
Polis with its own dsync.callback receives it as the callback's new, optional second argument.
A directory sync webhook delivery, webhook.delivery, now says which directory's events it carried, to which host and port, how
many, and which webhook event log entry it wrote: polis.dsync.webhook.log.id, the event ID the Admin Portal shows. Each event it
carried is also a polis.dsync.webhook.event log record, with its event ID, outcome, status code and attempt number, so one event
can be found in a batch and its retries followed. The dsync.webhook.event metric counts them by event type and outcome.
Batched events that the queue deletes unsent, because their directory was deleted or deactivated or no longer has a webhook, were
reported only in the application log. They are now counted by dsync.webhook.dropped, by event type and reason, and logged as
warnings with their event ID.
Like the other Polis logs, these records need a valid POLIS_LICENSE_KEY. The metrics do not.
See Webhook deliveries.
SAML logout completes against the connection that started it
When several SAML connections use the same identity provider, a logout response is now verified with the certificate of the connection that started the logout, and Polis redirects using that connection's allowed redirect URLs and default redirect URL.
A logout response whose issuer is not that connection's identity provider is rejected. A logout started before this release is rejected; start the logout again.
SCIM updates that collide with another request no longer drop its change
A SCIM PATCH of a user, or a change to a group's display name, that reached the database at the same time as another update of
the same record could write back over that other update. On CockroachDB, YugabyteDB and PostgreSQL at serializable isolation the
database reports such an overlap as a retryable conflict; Polis replayed the write but not the read it was built from, so the
record it wrote was the one it had read before the other request committed. These updates now read the record again before
replaying, the same way SSO connection updates already do, so the change the other request committed is carried into the replay
rather than overwritten. As with SSO connection updates, this narrows the window for a lost update without closing it: the
database only reports a conflict when the two writes overlap in time.
A replay that finds the user gone, because the request it lost the conflict to was the one that deleted it, now answers 404. A
PATCH whose write fails for any other reason now returns that error instead of an empty 200.
On PostgreSQL and YugabyteDB a deadlocked transaction is now replayed as well, where it previously surfaced as an internal server error. MySQL, MariaDB and PlanetScale already were, because they report a deadlock under the same code as a serialization conflict.
Verify the signature on Identity Federation logout requests
Identity Federation apps accept a new logoutRequestSigningCertificate field holding the service provider's X.509 certificate, in
PEM or bare base64 DER form. Set it and Polis verifies the signature on every SAML LogoutRequest that service provider sends to
/api/identity-federation/slo, for both the HTTP-POST and HTTP-Redirect bindings, and refuses the logout with 403 if the
signature does not verify. The field accepts a comma-separated list, so both certificates can be trusted while you rotate a key.
Set IDFED_REQUIRE_SIGNED_LOGOUT_REQUESTS=true to require a verified signature across every app. Apps with no certificate
configured can then no longer start a logout, so register the certificates first.
A verified request must also be addressed to this deployment's Single Logout endpoint, the one published in its IdP metadata, must
be no more than five minutes old by its own IssueInstant, and is accepted only once. So make sure the service provider sets
Destination and IssueInstant, gives each LogoutRequest its own ID, and keeps its clock within five minutes of yours.
Apps that leave the field unset behave as before. SHA-1 signatures are not accepted; configure a service provider that signs with SHA-256 or stronger.
v26.4.1
Polis removes /api/hello and the setTenantAndProduct library method
Polis no longer serves the /api/hello route, the @boxyhq/saml-jackson library no longer has setTenantAndProduct on its
directory sync stores, and Node.js 22.0 to 22.11 are no longer supported.
Breaking changes
/api/hello is removed
If you check that Polis started by requesting /api/hello, for example in a load balancer or container health check, use
/api/health instead. It responds with the Polis version and edition, and with status 503 when Polis can't connect to its
database.
setTenantAndProduct is removed
The directory sync users, groups, and webhookLogs stores no longer have setTenantAndProduct. It changed the shared store,
so concurrent requests for different tenants could read or write each other's data.
If you embed the library and call setTenantAndProduct, call withTenantAndProduct instead. It takes the same arguments and
returns a copy of the store bound to that tenant and product:
const { data } = await directorySyncController.users.withTenantAndProduct(tenant, product).getAll()
Use the returned copy for every call. Calls on the shared store itself fail with "Set tenant and product before using store."
Node.js 22.0 to 22.11 are no longer supported
Polis and the @boxyhq/saml-jackson library now need Node.js 20.19.0 or later in the 20 line, or 22.12.0 or later. Node.js 22.0
to 22.11 can't load the openid-client and jose dependencies without the --experimental-require-module flag. If you run one
of those versions, upgrade Node.js. The Polis container images already use Node.js 24.
FIPS 140-3 container images and Helm chart support for Kratos, Hydra, and Polis
Every Ory Enterprise License release now publishes FIPS 140-3 images of Ory Kratos, Ory Hydra, and Ory Polis next to the standard images, with the same tags:
europe-docker.pkg.dev/ory-artifacts/ory-enterprise-kratos/kratos-oel-fipseurope-docker.pkg.dev/ory-artifacts/ory-enterprise/hydra-oel-fips, also onus-docker.pkg.deveurope-docker.pkg.dev/ory-artifacts/ory-enterprise-polis/polis-oel-fips
The images support linux/amd64 and linux/arm64. Kratos and Hydra are built with the Go Cryptographic Module v1.0.0, NIST CMVP
certificate #5247. Polis uses Red Hat's update stream of the Red Hat Enterprise Linux 9 OpenSSL FIPS Provider, NIST CMVP
certificate #4857. That certificate lists no arm64 operating environment, and Red Hat supports the compliance claim only when the
host kernel runs in FIPS mode.
To deploy a FIPS image, set the image repository in the OEL Helm chart: oss.image.repository in kratos-oel and hydra-oel,
and image.repository in polis-oel.
In FIPS mode, Kratos refuses to start unless secrets.default, secrets.cookie, and secrets.pagination are set. Hydra refuses
to start unless secrets.system, secrets.cookie, and secrets.pagination are set, each with its own first value. The charts do
not map secrets.pagination, so pass it through oss.deployment.extraEnv.
The Polis FIPS image derives datastore keys with SHA-256 instead of RIPEMD-160, so it cannot read a database that the standard image wrote. Choose the image when you create a deployment, and do not switch an existing one. The Polis FIPS image does not support Microsoft SQL Server.
Signatures and SBOM and CBOM attestations for OEL images
Ory now signs every OEL image and publishes each signature next to its image, on the enterprise registries and on Cloudsmith. All OEL images except the debug variants also carry two signed attestations: a Software Bill of Materials in SPDX format, and a CycloneDX document with the Software Bill of Materials and the Cryptography Bill of Materials.
v26.4.0
OpenTelemetry metrics follow the standard protocol and configuration
Ory Polis now sends OpenTelemetry metrics in the protocol you configure. OTEL_EXPORTER_OTLP_METRICS_PROTOCOL and
OTEL_EXPORTER_OTLP_PROTOCOL accept grpc, http/protobuf and http/json; the metrics-specific variable takes precedence.
Previously only grpc was honored and every other value, including http/protobuf, sent OTLP over HTTP/JSON.
The export schedule can now be set with the standard OTEL_METRIC_EXPORT_INTERVAL and OTEL_METRIC_EXPORT_TIMEOUT variables, in
milliseconds, and the SDK's own diagnostic logging with OTEL_LOG_LEVEL. The previous OTEL_EXPORT_INTERVAL_MS and
OTEL_EXPORTER_DEBUG variables are deprecated but still work when the standard ones are unset.
Metrics are now flushed when Polis receives SIGTERM or SIGINT, so the values recorded since the last periodic export are no
longer lost on shutdown.
Breaking changes
When neither protocol variable is set, metrics are now sent as http/protobuf, the OpenTelemetry default, instead of http/json.
If your collector accepts only JSON, set OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json before upgrading.
If you set OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobuf, which the polis-oel Helm chart does by default, Polis now sends
protobuf as configured rather than JSON. This is the correct behavior, but a collector that was relying on receiving JSON at that
endpoint needs to accept protobuf.
26.3.19
This version contains only minor changes and improvements such as dependency updates.
26.3.18
This version contains only minor changes and improvements such as dependency updates.
v26.3.17
DSYNC_WEBHOOK_LOGS_TTL rejects a value that is not a duration
DSYNC_WEBHOOK_LOGS_TTL summed whichever <number><unit> tokens appeared anywhere in its value, so a typo carrying one was
accepted as a retention rather than reported: 1d garbage kept logs for a day, and typo1h for an hour. The release that added
the setting described this leniency, but it means a mistyped value changes how long logs are kept and says nothing about it.
A value must now be a duration end to end, such as 720h, 30d or 1d 12h. Anything else falls back to the seven-day default
and logs a warning, which is what an unrecognized value has always done. Unset still means seven days and an empty value still
keeps logs indefinitely, and now says so in a warning of its own, because a store with no expiry grows for as long as the
deployment runs.
Breaking: a value that carries a duration inside something else kept logs for that duration and now keeps them for seven days.
1d garbage and typo1h are the shapes this catches. Check the value before upgrading if you set this; spaced durations such as
1d 12h are unaffected and still mean what they say.
SSO_TRACES_TTL and DSYNC_WEBHOOK_LOGS_TTL now resolve their retention through one shared function, so a duration, an empty
value and a typo mean the same thing in both.
SSO_TRACES_TTL now applies in both directions
SSO_TRACES_TTL reached the trace store, which expires the records, but a second cleanup inside Polis deleted anything older than
a hardcoded week and never read the setting. Whichever of the two was shorter won, so a value below seven days took effect and a
value above it was silently capped at seven. The direction people set this for -- keeping traces long enough to investigate a
failure reported weeks later -- was the one that did nothing.
That cleanup is gone and retention is the store's own expiry, so the setting now means what it says. Leaving it unset still keeps traces for seven days.
SSO_TRACES_TTL still takes a number of hours, and now also takes a duration such as 720h or 30d, matching
DSYNC_WEBHOOK_LOGS_TTL. Set it to an empty value to keep traces indefinitely. A value that is not a duration end to end falls
back to the seven-day default and logs a warning, so a typo never silently changes retention.
Setting it to an empty value now survives option normalization as well. The package filled an unset trace TTL with its seven-day default using a falsy check, which turned the zero that means indefinite back into seven days, so there was no way to ask for it at all.
Breaking: a deployment that declares SSO_TRACES_TTL with an empty value kept traces for seven days and now keeps them
indefinitely. If you set the variable but leave it blank, either remove it or give it a retention such as 168h, otherwise the
trace table grows for as long as the deployment runs. Polis logs a warning at startup whenever traces are kept indefinitely.
Bulk deletes now remove every record, not just the first page
Deleting all SSO connections for a tenant and product removed only as many connections as a single database read returns, which is
capped by DB_PAGE_LIMIT and defaults to 50. The call still reported success, so a tenant with more connections than that was
left with working SSO connections after what looked like a complete deprovision. Deleting traces for a product had the same
problem and stopped after 50 traces.
Both now page through every matching record. If you have deprovisioned a tenant with more than DB_PAGE_LIMIT SSO connections,
run the delete again to clear whatever was left behind, or list the connections for that tenant and product to check.
On MongoDB, paged reads could also repeat some records and skip others when they shared a creation time, which bulk imports make common. Each record now appears exactly once.
Find a customer's SSO connections and directories by tenant
The Admin Portal listed every SSO connection and every directory a page at a time with no way to ask for one customer's, so finding them meant paging until they appeared, and finding both meant doing it twice.
Both lists now take a tenant and a product and answer with that customer's records. A tenant in either list is also a link to the same tenant on the other, so a connection leads to the directories beside it and back.
Both terms are needed and each has to match what was stored: records are indexed by tenant and product together, so this is a
lookup rather than a search. The same two parameters work on GET /api/admin/connections and GET /api/admin/directory-sync,
which answer with the customer's records in full and no pagination.
Reading a customer's directories by tenant and product returned only as many as a single database read yields, which
DB_PAGE_LIMIT caps and defaults to 50, so a customer with more directories than that saw a short list with nothing to indicate
records were missing. It now reads through to the end, which also affects GET /api/v1/dsync and the setup link that lists a
customer's directories.
Find a directory's users and groups by email or name
The Admin Portal listed a directory's users and groups a page at a time with no way to ask for one of them, so answering whether a person had been provisioned meant paging through the directory until their row appeared.
Both lists now take a term and answer with the matching record. The same parameters work on
GET /api/admin/directory-sync/{directoryId}/users, which takes email, and
GET /api/admin/directory-sync/{directoryId}/groups, which takes name. Either answers with the record itself and without
pagination, since a directory holds one user per address and one group per name.
The term has to match what the identity provider sent, in full: users are indexed by directory and email together and groups by directory and name, so this is a lookup rather than a search. A partial address or name is a different key and finds nothing, which the field says above the list.
A user whose email address changed after they were provisioned, or a group that was renamed, is still indexed under the value it was created with, so the lookup answers to that value and not to the current one. Those records remain in the unfiltered list.
SCIM user and group listings now page past the first set of records
Listing directory users or groups over SCIM reported the first page as the whole directory, so an identity provider reading the
list stopped after DB_PAGE_LIMIT records and never saw the rest. Group listings ignored startIndex and count altogether and
answered every request with the same records.
Both endpoints now honour startIndex and count and report a totalResults that keeps the caller paging until the directory is
exhausted. totalResults is a lower bound while more records remain and becomes exact on the final page, so a client that pages
until it has seen totalResults records now reads the whole directory. If a directory looked short in your identity provider, run
a synchronisation again to pick up the records that were never returned.
v26.3.16
Concurrent SSO connection updates no longer fail with an internal server error
Updating several SSO connections of the same tenant at the same time could fail with HTTP 500. On CockroachDB and YugabyteDB, which run every transaction at serializable isolation, those writes touch the same records and the database rolls one of them back with a retryable conflict. Polis reported the conflict as an error instead of replaying the transaction, so the caller saw a failure for a condition the database expects the client to absorb. A caller that retried the request made the next conflict more likely.
Polis now replays a write that loses such a conflict, a bounded number of times and with a randomized delay between attempts. Where the write is an update that was built from an earlier read, such as a change to an SSO connection, the replay reads the record again first, so it does not write back over a change another request made in between. MySQL, MariaDB and PlanetScale report a deadlocked transaction the same way and are replayed too. There is nothing to configure.
This reduces but does not remove the chance of one update overwriting another: the database only reports a conflict when the two writes overlap in time.
Updating an SSO connection that does not exist now answers 404 with SSO connection not found, where it previously failed with an
internal error. A replay reaches the same answer when the request it lost the conflict to was the one that deleted the connection.
On the Ory Network this failure reached project updates that changed SAML providers, which now succeed.
Polis can now run in FIPS 140-3 mode
Every cryptographic operation in Polis now goes through Node's node:crypto/WebCrypto rather than pure-JavaScript
implementations, and Polis gains a preview container image, polis-oel-fips, that runs with an OpenSSL FIPS provider active so
that only FIPS-approved algorithms are available.
The preview image is built and security-scanned on every release, but it is not yet published to the enterprise registry or Cloudsmith, and the release pipeline's runtime tests do not yet cover it. Contact us if you want to evaluate it.
Existing deployments are unaffected. If you do not run the FIPS image, nothing about your data or your integrations changes:
datastore keys, connection clientIDs, setup-link IDs, identity federation app IDs, the SP entity ID, and the idHash claim all
keep their current values. They are still derived with RIPEMD-160, which now comes from OpenSSL instead of a JavaScript package —
the same digest, byte for byte.
What changes if you run the FIPS image:
- Datastore keys and the IDs derived from them use SHA-256, because RIPEMD-160 is not FIPS-approved at any implementation. They
are 64 hex characters rather than 40, so connection
clientIDs, setup-link IDs, identity federation app IDs and theidHashclaim are longer under the FIPS image than under the standard one. - A FIPS datastore and a non-FIPS datastore are not interchangeable. The two digests produce different keys, so a database written by one cannot be read by the other. Pick one mode per deployment. Moving an existing deployment into (or out of) FIPS mode requires re-keying your data; contact us for the migration path. Polis logs which digest is active at startup so you can confirm what a running process is using.
- A SAML signing certificate that Polis generated for itself is stored under a digest-derived key, so it is re-keyed along with
everything else and identity providers holding the old certificate must be updated. Deployments that set
PUBLIC_KEY/PRIVATE_KEYare unaffected — an operator-supplied certificate never goes through the datastore. DB_TYPE=mssqlis refused, both at startup and by the database migration job: its NTLM authentication depends on MD4, which FIPS does not permit. It is the only database Polis supports that FIPS rules out —postgres,cockroachdb,yugabytedb,mysql,mariadbandsqliteall work, as do the non-SQL engines (mongo,redis,dynamodb,planetscale).- An IdP that still signs SAML responses with RSA-SHA1, or encrypts assertions with RSA-PKCS#1 v1.5 key transport or 3DES, will fail verification. Reconfigure it for RSA-SHA256, RSA-OAEP, and AES. Polis now reports these failures with a message naming the likely cause and the IdP-side fix instead of the raw OpenSSL error.
- Polis warns at startup, without blocking, when
DB_ENCRYPTION_KEYis unset or when it is using an auto-generated SAML signing certificate.
Applies to both images:
- Newly generated default SAML signing certificates use RSA-3072 keys (previously RSA-2048), and are created through WebCrypto. Existing certificates are unchanged and keep verifying.
A note on what the FIPS image is: its cryptography is confined to FIPS-approved algorithms and it provably runs in FIPS mode, independent of the host. The FIPS provider is compiled from upstream OpenSSL source, so it is not a CMVP-validated module and this image should not be described as FIPS 140-3 validated.
See what a webhook delivery carried without opening it
The Admin Portal listed a directory's webhook events by endpoint, status code and time. Every row carried the endpoint it was sent to and nothing about its contents, so finding the delivery for one person meant opening events one at a time to read their JSON.
A row now names the event types the delivery carried and who or what they applied to: a user by email address, a group by name, a membership change by both. A delivery that carried a batch of events reports how many users and groups it touched rather than naming them. The time a delivery was sent is a link to it.
The event page lists each event in the delivery beside its subject and shows the payload your endpoint received, with a button to copy it. It previously showed the whole stored record; the fields that wrapped the payload are now listed above it rather than buried in the JSON.
v26.3.15
DynamoDB deployments now honor the page limit when listing records
When DB_ENGINE is dynamodb, endpoints that list records by an index returned every matching record in one response and ignored
the requested page size. They now return at most DB_PAGE_LIMIT records per call, which matches every other database engine.
DynamoDB cannot skip records, so pageOffset is ignored on this engine. When you page through connections, directory users or
groups, or SSO traces with the management API, read the pageToken from the response and pass it back to fetch the next page.
SCIM is the exception, because it has no page token. The SCIM endpoints an identity provider calls page with startIndex, which
DynamoDB ignores, so on this engine an identity provider reads the first DB_PAGE_LIMIT users or groups of a directory and cannot
reach the rest. Do not run directory sync on DynamoDB with directories larger than DB_PAGE_LIMIT.
Index rows are also written in batches now, and any row DynamoDB reports as unwritten is retried. Under heavy write throttling a record could previously be saved but stay unreachable through one of the indexes that point at it.
Listing SSO connections now returns all of them
Reading the SSO connections for a tenant and product returned only as many as a single database read yields, which DB_PAGE_LIMIT
caps and defaults to 50. A tenant with more connections than that saw a short list with nothing to indicate records were missing,
and the same limit applied when reading connections for several tenants at once, when filtering by strategy, and when looking up
connections by identity provider entity ID.
All of these now read through to the end of the list. If you have more connections than DB_PAGE_LIMIT under one tenant and
product, expect the list to be longer than before.
Support IdP-initiated SAML login
Enterprise users can now start a login from their identity provider's app launcher (for example the Okta or Azure AD dashboard) and land in your application with a valid Ory session.
When an identity provider posts an unsolicited SAML response, Ory Polis validates it and redirects the browser to a new Kratos
entry point with a single-use authorization code. Kratos starts a regular SP-initiated login flow — with the usual state, CSRF,
and PKCE protections — and forwards the code as a code_hint to the Polis authorize endpoint. Polis consumes the hint and
completes the flow immediately, without a second round-trip to the identity provider.
Standalone Ory Polis already supports IdP-initiated SAML login through its direct redemption flow. This change adds the Kratos
side: an entry point that folds the unsolicited response into a regular SP-initiated Kratos login, wired together with Polis
through the code_hint.
To enable it on Ory Network, turn on the "IdP-initiated login" toggle in the SAML provider settings in Ory Console. Ory then points the SAML connection's default redirect URL at the entry point automatically.
To enable it on a self-hosted Ory deployment that runs Kratos and Polis together:
- Set
idp_initiated_login_enabled: trueon the SAML (jackson) provider in your Kratos configuration. - Point the Polis SAML connection's
defaultRedirectUrlathttps://<your-project>/self-service/methods/saml/idp-initiated/<provider>. - Run Polis with
IDP_ENABLED=true.
IDP_ENABLED is the deployment-wide switch. A connection follows it unless it sets idpInitiatedLoginEnabled explicitly: true
always allows IdP-initiated login for that connection and false always blocks it. On Ory Network the switch stays off and the
Console toggle sets the per-connection flag, so no deployment-wide change is needed.
The code hint is single use, expires with the code store TTL, and is only valid for the exact connection it was issued for. SP-initiated codes cannot be used as hints. If a hint is invalid or already consumed, the user is sent through the regular SP-initiated redirect instead — usually completing silently against the identity provider's existing session.
v26.3.14
Directory Sync applies SCIM replace and clear operations on group members
SCIM PATCH operations that replace a group's full membership (op: replace with path members, or a pathless replace whose
value carries a members list) and operations that clear the membership (op: remove on members without a value) are now
applied. Previously these operations returned 200 but were ignored, so group membership in Directory Sync could silently drift
from the identity provider. Membership changes applied this way emit the usual group.user_added and group.user_removed webhook
events, only for members that actually change. The current membership is processed page by page, so replacing the membership of a
group larger than DSYNC_MAX_INLINE_GROUP_MEMBERS works even though inline reads of it do not. On the Redis store, indexed reads
are now ordered by creation time so that paging through them is stable.
Attribute paths in group PATCH operations (members, displayName, and the members[value eq "..."] filter form) are matched
case-insensitively, as RFC 7643 requires.
A PATCH remove operation without a path is now rejected with 400 noTarget, as required by RFC 7644, instead of being
misapplied as an attribute update. A members operation whose value is not a list of member references is rejected with 400
instead of being silently ignored.
SCIM discovery endpoints for Directory Sync
Each directory's SCIM endpoint now serves the SCIM 2.0 discovery endpoints defined in RFC 7644: ServiceProviderConfig,
Schemas, and ResourceTypes. Identity providers and SCIM clients can use them to validate the connection and discover which
protocol features are supported. The endpoints require the directory's bearer token, the same credential used for the Users and
Groups endpoints. No action is required for existing directories.
SCIM error responses now include the status and scimType fields
Error responses from the Polis SCIM endpoints now carry the status attribute required by RFC 7644 and, where the specification
defines a keyword for the condition, a scimType attribute. A duplicate user or group returns "scimType": "uniqueness" on
its 409.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "User already exists"
}
Identity provider integrations that read these machine-readable fields can now tell a permanent uniqueness conflict apart from
other failures, instead of matching on the free-text detail. The change is additive: HTTP status codes and successful responses
are unchanged, so existing integrations keep working without any action.
Send the SSO connection client secret as a header when deleting a connection
DELETE /api/v1/sso now accepts the connection's client secret in the x-polis-client-secret header. Use it instead of the
clientSecret query parameter, which is deprecated but still works.
A secret in a query string ends up in browser history, proxy access logs, and tracing spans. Sending it as a header keeps it out of the URL and reduces that exposure.
To migrate, move the value from the query string to the header:
DELETE /api/v1/sso?clientID=<client-id>
x-polis-client-secret: <client-secret>
If both are sent, the header takes precedence. Deleting by tenant and product is unchanged.
The setup link endpoint DELETE /api/setup/{token}/sso-connection no longer needs a clientSecret query parameter at all — pass
only clientID. A secret sent by an older client is ignored. The endpoint now returns 404 instead of 500 when the clientID
does not exist.
The admin portal endpoint DELETE /api/admin/connections takes only clientID for the same reason, and now answers 404 for a
clientID that does not exist, where it previously reported success.
Store SCIM PATCH values addressed by path inside their schema extension
A SCIM PATCH can address an attribute inside a schema extension in two ways. It can name the extension and pass an object of
attributes:
{
"op": "replace",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
"value": { "manager": { "value": "61bb1e4c" } }
}
Or it can put the attribute in the path and pass its value directly:
{
"op": "replace",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager",
"value": { "value": "61bb1e4c" }
}
Directory sync told the two apart by the type of the value, so the second form was mistaken for the first whenever its value was
an object or an array. The attribute was stored as a literal top-level attribute named
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager, beside the extension rather than inside it, where nothing
could read it: absent from SCIM responses, unusable in webhook payloads, and untouched by a later remove operation on the same
path.
The two forms are now told apart by whether the attribute arrived in the operation's path, so a value is stored inside its
extension whether it is a string, an object or an array, and for every schema rather than only the ones this service defines. A
SCIM filter in a path, such as roles[value eq "admin"].display, updates the matching entry inside the extension instead of being
stored as a literal attribute name.
Every write now also folds attributes left beside an extension by an earlier release back into it, so affected users are repaired as their identity provider next updates them. An attribute already present in the extension is kept in preference to the stray one.
v26.3.13
Identity Federation user identifiers are now unique per upstream connection
The sub claim an OpenID Connect Identity Federation app returns, and the id field its /oauth/userinfo endpoint returns, are
now derived from the upstream connection that authenticated the person as well as from the identifier that connection asserted.
Until now the app passed the upstream identifier through unchanged, so two connections behind one app could return the same
identifier for two different people.
Every identifier an Identity Federation app returns is different after this upgrade. Directly configured SSO connections are unaffected: their identifiers do not change.
The ID token and the /oauth/userinfo response both carry two further claims:
upstream_subholds the identifier the upstream provider asserted. It is permanent, and SAML single logout needs it.legacy_subholds the identifier the app returned before this change, for as long asOPENID_IDFED_LEGACY_SUBJECTistrue. It is there so you can move your existing accounts across.
Moving your accounts across
On each sign-in, look the account up by sub. If you find it, there is nothing more to do. If you do not, look it up by
legacy_sub, and save sub in place of the old identifier.
Key your accounts on sub and on nothing else. Neither legacy_sub nor upstream_sub is unique across the connections behind an
app, so neither can identify an account on its own.
That is about which value identifies an account, not about what you may keep alongside it. If you send SAML single logout requests
you do need to store upstream_sub as well, as a property of the account rather than as its key. See below. legacy_sub is
migration data only: use it to find the account, save sub, and keep nothing. Set OPENID_IDFED_LEGACY_SUBJECT=false once your
applications have finished re-keying; that claim will be removed in a later release.
Single logout
If you send SAML single logout requests, pass upstream_sub as the nameId rather than id. The upstream provider knows the
person by that value; the id an Identity Federation app now returns is scoped to the app, and the provider will not recognise
it.
If you set OPENID_SUBJECT_PREFIX
That prefix, which is off unless you turned it on, no longer applies to Identity Federation apps; the tenant and product of an app
never distinguished one of its upstream connections from another. legacy_sub still reproduces the prefixed identifier your
integration stored, so the lookup above works either way.
If two people shared an identifier
They now receive different ones. The account you already hold follows whichever of them signs in first, and the other is provisioned as a new account, so review those by hand.
Breaking changes
Applications that store the sub or userinfo id of an Identity Federation app stop matching their existing accounts at this
upgrade. Re-key them through legacy_sub as described above before setting OPENID_IDFED_LEGACY_SUBJECT=false. Without that,
everyone signing in through an Identity Federation app appears as a new person.
Applications that send SAML single logout requests with the identifier they stored must switch to upstream_sub. Requests
carrying the new id will not identify the person to the upstream provider.
v26.3.12
Return the SCIM manager attribute in its complex form
The SCIM endpoint returned the enterprise extension's manager attribute exactly as the identity provider sent it. Microsoft
Entra ID sends the manager's user id as a bare string, so a user with a manager assigned came back as:
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"manager": "61bb1e4c-a894-4899-be23-6de941710dbf"
}
RFC 7643 section 4.3 defines manager as a complex attribute, and the Entra ID provisioning client refuses to read that response
back. Every user who had a manager assigned failed to provision with SystemForCrossDomainIdentityManagementServiceIncompatible,
while users without a manager synced normally.
SCIM responses now return manager as a complex attribute:
"manager": { "value": "61bb1e4c-a894-4899-be23-6de941710dbf" }
Affected users provision on the next sync cycle with no reconfiguration, including users already stored before this release.
Only the SCIM response is normalized. The stored user attributes, the raw attributes on user.created, user.updated and
user.deleted webhook events, and the directory sync API all keep the shape the identity provider sent, so no change is needed in
your application.
26.3.11
This version contains only minor changes and improvements such as dependency updates.
26.3.10
This version contains only minor changes and improvements such as dependency updates.
26.3.9
This version contains only minor changes and improvements such as dependency updates.
26.3.8
This version contains only minor changes and improvements such as dependency updates.
v26.3.7
Require client authentication when exchanging IdP-initiated authorization codes
Polis now requires client authentication on the OAuth token endpoint for authorization codes issued by IdP-initiated SSO flows. A
token request must supply a valid client_secret. Requests that omit all credentials are rejected with
401 Please specify client_secret or code_verifier.
Previously, authorization codes from IdP-initiated flows had no associated session, and every client-authentication branch on the
token endpoint was guarded by that session. A token request that omitted client_id and client_secret skipped all checks and
was issued an access token. Anyone who intercepted such a code could exchange it without knowing the application's client secret.
SP-initiated flows were not affected. Integrations that already send client credentials continue to work unchanged.
Breaking change. Any integration that exchanges an IdP-initiated authorization code without a client_secret (relying on
the previous behavior) now receives 401. To restore access, send client_secret on the token request. If you do not use
per-connection secrets, send the deployment's CLIENT_SECRET_VERIFIER value (which defaults to dummy when unset).
Bind Google Directory Sync OAuth callback to a single-use state
The Google Directory Sync OAuth flow now uses an unguessable, single-use state handle that is stored server-side and bound to
the directory the flow was started for. The callback resolves the directory from that stored record and deletes it on use, instead
of trusting a directoryId supplied in the state query parameter.
Previously the state carried the directoryId with no binding, so a crafted or replayed callback could install one tenant's
Google Workspace tokens onto another tenant's directory (CWE-352). Callbacks with a missing, unknown, or already-used state are
now rejected. Any authorization flow started before the upgrade must be restarted from the directory's authorization URL.
Clean up partially initialized controllers when startup fails
If constructing the Polis controllers fails after the database has been created -- for example when a later initialization step
throws -- controllers() now closes the database and stops the background workers it already started before rethrowing, instead
of leaving their timers and connection pools on the event loop with no close() for the caller to invoke. The database wrapper's
close() now also always clears its stats-reporting interval, even if the underlying driver close rejects.
This affects self-hosted Polis.
Fix SCIM authentication bypass on non-SCIM directories
Public SCIM endpoints now reject requests to directories that do not use SCIM (such as Google), and reject any request that presents an empty or missing bearer token. Previously, directory types that store an empty SCIM secret were authenticated by a request that also carried no token, because a constant-time comparison of two empty values returns true. An attacker who knew a directory id could read and write the tenant's SCIM users and groups without credentials. This affected the self-hosted and OEL distributions; the endpoint is not exposed on the Ory Network.
Fixed a security issue
This release includes a fix for a security issue. Additional details will be shared with customers directly.
Release background timers on shutdown and await webhook batch processing
Embedding Polis and calling the controllers' close() now stops every background timer it started, so the host process can shut
down cleanly. Previously close() left the SSO traces daily cleanup timer and the in-memory store's TTL cleanup timer running,
which kept the Node.js event loop alive after shutdown and forced callers to terminate the process explicitly.
The webhook batch processor now waits for a batch to finish before it returns. EventProcessor.process() previously started
processing without awaiting it, so its error handling never ran and the "already running" guard could clear before a batch
completed, allowing overlapping runs. It now awaits the batch, logs any failure through the configured logger, and holds the run
guard for the full duration.
This affects self-hosted Polis.
SAML IdP Single Logout (SLO) for Identity Federation
Polis can now act as a SAML IdP for Single Logout. When a Service Provider sends a SAML LogoutRequest, Polis initiates an OIDC RP-Initiated Logout against the upstream OIDC provider and returns a signed SAML LogoutResponse to the SP. This mirrors the existing login flow.
SAML responses are protected against assertion replay
Jackson now rejects a SAML response whose assertion has already been used. Each consumed assertion is recorded until it expires, so a captured response can no longer be submitted more than once to obtain a fresh login.
This closes a gap in the IdP-initiated flow. Those responses carry no in-flight request to consume, so previously the same signed response could be replayed within its validity window to mint a new authorization code for the victim on each submission. Service-provider-initiated flows were already protected because their one-time request state is consumed on success.
The anti-replay binding (InResponseTo) is now read only from the signed part of the assertion, so it can no longer be bypassed
by editing the unsigned response wrapper.
Single Logout responses now verify the Identity Provider signature before acting on the response status or request ID, so an unsigned or tampered logout response is rejected outright.
Setup links are now scoped to their service
A setup link is now rejected with 403 when it is used for a service it was not issued for. A Single Sign-On setup link can only
manage SSO connections, and a Directory Sync setup link can only manage directories.
Before, the service on a setup link was not checked at the setup routes, so the holder of a Directory Sync link could create SSO connections for the tenant (and the reverse), which is an authorization bypass. The SSO connection route also no longer accepts the tenant, product, or redirect URLs from the request body; it takes them only from the setup link.
v26.3.6
Harden OIDC discovery URL fetching against SSRF
Creating or updating an OIDC SSO connection with an oidcDiscoveryUrl now validates the fetch target, and the value is
re-validated whenever it is fetched during an authorize or callback flow.
Previously the discovery URL was stored verbatim with no scheme, host or address validation and fetched on every authorize and callback. A caller with an SSO setup link or the deployment API key could point it at an internal address — such as the cloud instance metadata service or an in-cluster service — and read a reachability and response oracle from the resulting SSO trace and error redirect.
The discovery URL now must be an HTTPS URL that does not resolve to a non-public address (loopback, link-local, carrier-grade NAT, private, unique-local, reserved and unspecified ranges for both IPv4 and IPv6, and IPv4-mapped IPv6 literals). Every outbound OIDC request — discovery, token, userinfo and JWKS — is validated at connection time, so a hostname that resolves to an internal address, or one that changes its answer between validation and the request (DNS rebinding), is blocked.
The localhost hostname is now blocked by default, so a connection owner cannot reach services on the loopback interface of the
host or pod. Local development against an IdP on localhost is enabled automatically in a Node development environment, or
explicitly by setting POLIS_ALLOW_LOCALHOST_OUTBOUND=true.
The in-cluster federation tunnel — which rewrites a <slug>.projects.oryapis:8080 discovery URL to the internal hydra-public
service — keeps working unchanged; its rewrite target is a fixed, code-controlled in-cluster service, never taken from the
request. A self-hosted deployment that does not use Ory federation can turn the tunnel off with
POLIS_DISABLE_INTERNAL_ORY_FEDERATION=true, after which a *.projects.oryapis discovery URL is validated like any other host.
Upgrade impact
Because the discovery URL is re-validated on every authorize and callback, an existing OIDC connection whose discovery, token,
userinfo or JWKS endpoint is plain HTTP, localhost, or a private, loopback, link-local or other non-public address will start
failing those flows immediately after upgrading. On Ory Network this is not expected — providers are public HTTPS hosts.
Self-hosted (OEL/OSS) deployments should audit existing OIDC connections before upgrading and re-point any such connection at a
public HTTPS endpoint.
For a self-hosted deployment whose identity provider legitimately runs on an internal network, set
POLIS_ALLOW_PRIVATE_IP_OUTBOUND=true to allow HTTPS fetches to RFC1918 and IPv6 unique-local addresses. The opt-in is narrow:
HTTPS is still required, and link-local (including the cloud metadata address), loopback, carrier-grade NAT and reserved ranges
stay blocked. An IdP on the loopback interface uses POLIS_ALLOW_LOCALHOST_OUTBOUND=true instead.
This path is not exposed through the Ory Network gateway.
Harden SAML metadata URL fetching against SSRF
Creating or updating a SAML SSO connection with a metadataUrl now validates the fetch target against a strict allow list before
and during the request.
The previous check only rejected RFC1918 private IPv4 literals and IPv6 unique-local literals. It let through loopback, link-local
(including the cloud metadata address 169.254.169.254), carrier-grade NAT, reserved and unspecified addresses, every IPv6
literal, and every DNS hostname, and the fetch followed HTTP redirects without re-validating the target. A caller with an SSO
setup link or the SSO API key could use this to read internal endpoints such as the cloud instance metadata service.
The fetch now requires an HTTPS URL, resolves the hostname and rejects any address that is not a public unicast address (covering loopback, link-local, carrier-grade NAT, private, unique-local, reserved and unspecified ranges for both IPv4 and IPv6, and IPv4-mapped IPv6 literals). Resolution happens at connection time, so a hostname that resolves to an internal address — or one that changes its answer between validation and the request (DNS rebinding) — is blocked. Redirects are no longer followed, so a public host cannot redirect the request into an internal address. Unparseable input fails closed.
The localhost hostname is blocked by default so a connection owner cannot reach services on the loopback interface of the host
or pod. Local development against an IdP on localhost is enabled automatically in a Node development environment, or
explicitly by setting POLIS_ALLOW_LOCALHOST_OUTBOUND=true.
Upgrade impact
The previous check allowed localhost and every non-RFC1918 address, so a SAML connection configured with a metadataUrl over
plain HTTP, on localhost, or on a private, loopback or link-local address may now be rejected when its metadata is fetched or
refreshed. On Ory Network this is not expected. Self- hosted (OEL/OSS) deployments should audit existing SAML connections before
upgrading and re-point any such metadataUrl at a public HTTPS endpoint.
For a self-hosted identity provider on an internal network, set POLIS_ALLOW_PRIVATE_IP_OUTBOUND=true to allow HTTPS fetches to
RFC1918 and IPv6 unique-local addresses; HTTPS is still required and link-local (including the cloud metadata address), loopback,
carrier-grade NAT and reserved ranges stay blocked. An IdP on the loopback interface uses POLIS_ALLOW_LOCALHOST_OUTBOUND=true
instead.
This path is not exposed through the Ory Network gateway.
Add YugabyteDB support to Ory Polis
Ory Polis can now use YugabyteDB as its backing database. YugabyteDB is PostgreSQL wire-compatible, so it reuses the existing PostgreSQL driver and needs no additional dependencies. This gives self-hosted and enterprise deployments a distributed, horizontally scalable storage option alongside the existing engines (PostgreSQL, MySQL, MariaDB, Microsoft SQL Server, SQLite, CockroachDB, MongoDB, Redis, PlanetScale, and DynamoDB).
Configuration
Select YugabyteDB by setting the SQL engine and the yugabytedb type, then point the connection URL at your YugabyteDB YSQL
endpoint (default port 5433):
DB_ENGINE=sql
DB_TYPE=yugabytedb
DB_URL=postgresql://yugabyte@localhost:5433/yugabyte
Fix cross-tenant admin takeover through SAML admin-portal login
The Polis Admin Portal SAML login now accepts a login only when it went through the Admin Portal's own SSO connection. Previously
both the IdP-initiated (boxyhq-saml-idplogin) and SP-initiated (boxyhq-saml) flows trusted a login completed against any
tenant's SSO connection in the deployment. An attacker could complete a SAML login against a connection they control and receive
an Admin Portal session with read and write access to every tenant's SSO connections and secrets.
The fix pins the resolved connection's tenant and product to the Admin Portal defaults (_jackson_boxyhq /
_jackson_admin_portal) and rejects anything else — in the IdP-initiated provider's authorize() and in the sign-in callback for
the SP-initiated provider.
This affects self-hosted Polis (OSS) and the polis-oel chart. It is not reachable on the Ory Network, where the gateway does not
route /api/auth/** to Polis.
Fix cross-tenant data leak in concurrent SCIM directory-sync requests
SCIM directory-sync requests could leak users and groups across tenants when two requests for different directories were processed at the same time.
The request handler bound the directory's tenant and product onto a single, process-wide store instance that every request shared.
Because that binding was read again at each database call after intervening awaits, a second request for a different directory
could overwrite it mid-flight. A user or group created for directory A could then be written into directory B's namespace:
directory B's directory-sync APIs would return directory A's data, while directory A's own read returned nothing even though it
received 201 Created.
The handler now binds a request-scoped copy of the store per request, so concurrent requests can never observe one another's
tenant and product. The directory-sync admin and management API routes (/api/v1/dsync/*, /api/admin/directory-sync/*) were
bound the same way and are migrated too, which closes a related window where a concurrent read could redirect a "delete all
webhook events" request to another tenant's namespace. This affects self-hosted Polis; SCIM is not routed through the Ory Network
gateway.
Reject SAML responses that declare a DTD and stop leaking parser errors
The SAML assertion consumer service (POST /api/oauth/saml) parsed the base64-decoded SAMLResponse with document type
definition (DTD) processing enabled. Because a SAML response never legitimately contains a DTD, this exposed the XML parser to
XXE-class attacks: a crafted DOCTYPE with a SYSTEM entity produced different parser errors depending on whether a referenced
path existed, giving an unauthenticated caller a file-existence oracle. Internal parser error text was also returned to the caller
through the polis_error cookie, which made the differential observable.
Polis now rejects any SAML response that declares a DOCTYPE/DTD before it reaches the parser, and returns a generic error to the
client. Internal error detail is logged server-side only and is no longer disclosed to the caller: the polis_error cookie
returns a generic message for server errors, and the OAuth error_description returned on the redirect no longer includes raw
parser or exception text. Standard OAuth error codes and OIDC provider error descriptions are still forwarded.
Reject invalid magic-link tokens in the Polis admin portal
The admin-portal magic-link (email) sign-in accepted any token value. The next-auth adapter looked up the stored verification
token by email but returned the record without checking that the presented token matched it. Because next-auth treats any non-null
adapter result as valid and the portal grants the admin role to every address, an unauthenticated attacker could complete the
email callback for any address — including an administrator's — with an arbitrary token and receive an authenticated admin
session. This required magic-link login to be enabled (NEXTAUTH_ACL set together with an SMTP host); a default install with
NEXTAUTH_ACL unset was not affected.
Polis now returns the verification token only when the presented value matches the stored one, and consumes it on that single successful use. Non-matching or missing tokens are rejected, closing the bypass (GHSA-j4rr-hrm4-hx4j).
Validate SAML SingleLogout URLs to prevent stored XSS and open redirect
SAML connection metadata is now scheme-checked on its SingleLogout (SLO) bindings, not just its login (SSO) bindings. Previously,
the SingleLogoutService Location from an identity provider's metadata was stored without validation and later written into the
auto-submitting form and redirect served by the unauthenticated /api/logout endpoint. A javascript: URL placed in that field
executed in the browser of anyone who opened the tenant's logout link (stored cross-site scripting), and a crafted redirect
binding could send that visitor to an arbitrary host (open redirect).
Creating or updating a SAML connection now rejects any SLO Location that does not use an http or https scheme, matching the
check already applied to SSO bindings. The localhost allowance in that check now also requires an http/https scheme:
previously it inspected only the host, so a javascript://localhost/... URL parsed with hostname localhost and slipped through.
As defense in depth, the logout request builder re-checks the SLO scheme at the point of use, so a connection that already carries
a malicious SLO URL can never emit it into the /api/logout form or redirect. Existing connections that carry a malicious SLO URL
should still be recreated or updated so the stored value is revalidated.
Validate the post-logout redirect URL in SAML Single Logout
The unauthenticated GET /api/logout endpoint accepted a redirectUrl query parameter and, after the identity provider completed
Single Logout, redirected the browser to it without checking it against the connection's configured redirect URL allow-list. An
attacker could send a victim a logout link with an arbitrary redirectUrl and have Polis redirect them to an external origin
(open redirect, CWE-601).
Polis now validates redirectUrl against the connection's allow-list when the logout request is created and rejects a disallowed
or malformed value with 403 Redirect URL is not allowed. — the same check the OAuth authorize flow applies to redirect_uri. If
a stored value no longer matches the connection resolved from the logout response, the callback falls back to the connection's
defaultRedirectUrl.
This affects self-hosted Polis (OSS and OEL). The Ory Network gateway does not expose /saml/api/logout and is not affected.
26.3.5
This version contains only minor changes and improvements such as dependency updates.
26.3.4
This version contains only minor changes and improvements such as dependency updates.
26.3.3
This version contains only minor changes and improvements such as dependency updates.
26.3.2
This version contains only minor changes and improvements such as dependency updates.
v26.3.1
Serve Polis over HTTPS with a built-in TLS sidecar
The Polis Helm chart can now terminate TLS in front of Polis without an external ingress. Set tlsSidecar.enabled: true and point
tlsSidecar.tls.secretName at an existing Secret that holds tls.crt and tls.key (managed by you or cert-manager):
tlsSidecar:
enabled: true
tls:
secretName: polis-tls
The chart then runs an nginx sidecar that terminates HTTPS and proxies to Polis in the same pod. The Service keeps the same port
and name; when the sidecar is enabled the Service routes to it (HTTPS) instead of the plain-HTTP Polis listener, so plain HTTP is
no longer served. The sidecar image defaults to docker.io/nginxinc/nginx-unprivileged:stable and is overridable via
tlsSidecar.image.
The feature is off by default, so existing deployments are unchanged and continue to serve plain HTTP.
For advanced cases, the chart also exposes deployment.extraContainers, deployment.extraVolumes,
deployment.extraVolumeMounts, and deployment.extraEnvs to add arbitrary sidecars or volumes.
26.3.0
This version contains only minor changes and improvements such as dependency updates.
v26.2.22
Fix cross-tenant identity provider selection via idp_hint in Polis
The idp_hint parameter on the SAML Identity Federation and SSO endpoints selected an upstream identity provider connection by id
without checking that the connection belonged to the requesting tenant and product. On a multi-tenant deployment, a co-tenant
could supply the id of a connection in their own tenant as idp_hint for another tenant's federation app, routing the login to an
identity provider they control and obtaining a Polis-signed assertion audienced to the victim's downstream service provider.
Polis now scopes the hinted connection to the requesting flow. A hint is accepted only when the connection belongs to the
requested tenant and product, to one of a multi-tenant federation app's tenants, or — for IdP-initiated SSO — to the identity
provider that issued the response. Out-of-scope hints are rejected with 403. The upstream-response callback re-checks that the
selected connection is within the session's tenant scope.
In-scope use of idp_hint, including for multi-tenant federation apps, is unchanged.
26.2.21
This version contains only minor changes and improvements such as dependency updates.
26.2.20
This version contains only minor changes and improvements such as dependency updates.
v26.2.19
Optionally return group members in the SCIM Groups API
The SCIM Groups API can now return a group's members. Reading a group with GET /api/scim/v2.0/{directoryId}/Groups/{groupId} or
listing groups with GET /api/scim/v2.0/{directoryId}/Groups returns an empty members list by default. Add
includeMembers=true to the request to include the current members instead.
Members stay omitted by default to avoid loading very large memberships into a single response. When includeMembers=true is set
and a group has more than 500 members, the request returns a 400 error and directs you to the paginated group members endpoint,
rather than returning a partial or oversized response.
The 500-member limit is configurable. Set the DSYNC_MAX_INLINE_GROUP_MEMBERS environment variable (or the
dsync.maxInlineGroupMembers option when embedding the library) to raise or lower it.
v26.2.18
Configurable retention for SCIM webhook event logs
You can now set how long SCIM (directory sync) webhook event logs are kept, using the DSYNC_WEBHOOK_LOGS_TTL environment
variable. Set it to a duration such as 720h or 30d.
If you leave the variable unset, logs keep the existing 7-day retention. Set it to an empty string to keep logs indefinitely. An unrecognized value falls back to the 7-day default and logs a warning, so a typo never disables retention.
26.2.17
This version contains only minor changes and improvements such as dependency updates.
26.2.16
This version contains only minor changes and improvements such as dependency updates.
26.2.15
This version contains only minor changes and improvements such as dependency updates.
26.2.14
This version contains only minor changes and improvements such as dependency updates.
26.2.13
This version contains only minor changes and improvements such as dependency updates.
26.2.12
This version contains only minor changes and improvements such as dependency updates.
26.2.11
This version contains only minor changes and improvements such as dependency updates.
26.2.10
This version contains only minor changes and improvements such as dependency updates.
v26.2.9
Patch security vulnerabilities in dependencies
Bump several dependencies to patched versions to address security advisories reported by Dependabot.
Notable updates:
github.com/jackc/pgx/v5tov5.9.2across all Go modules (SQL injection via placeholder confusion in dollar-quoted string literals).github.com/moby/spdystreamtov0.5.1(denial of service on container runtime interface).go.opentelemetry.io/oteltov1.41.0(remote DoS amplification via multi-value baggage header).postcssto>=8.5.10(XSS via unescaped</style>in CSS stringify output).uuidto>=14.0.0(missing buffer bounds check in v3/v5/v6 generators).@xmldom/xmldomto>=0.8.13(XML node injection and uncontrolled recursion).axios,follow-redirects,lodash,picomatch,brace-expansion,serialize-javascript,yaml,file-type,i18next-fs-backend,@nestjs/coreto their respective patched versions.
26.2.8
This version contains only minor changes and improvements such as dependency updates.
26.2.7
This version contains only minor changes and improvements such as dependency updates.
26.2.6
This version contains only minor changes and improvements such as dependency updates.
26.2.5
This version contains only minor changes and improvements such as dependency updates.
26.2.4
This version contains only minor changes and improvements such as dependency updates.
26.2.3
This version contains only minor changes and improvements such as dependency updates.
26.2.2
This version contains only minor changes and improvements such as dependency updates.
26.2.1
This version contains only minor changes and improvements such as dependency updates.
26.2.0
This version contains only minor changes and improvements such as dependency updates.
v26.1.18
Fixed a security issue
This release includes a fix for a security issue. Additional details will be shared with customers directly.
26.1.17
This version contains only minor changes and improvements such as dependency updates.
26.1.16
This version contains only minor changes and improvements such as dependency updates.
26.1.15
This version contains only minor changes and improvements such as dependency updates.
26.1.14
This version contains only minor changes and improvements such as dependency updates.
26.1.13
This version contains only minor changes and improvements such as dependency updates.
26.1.12
This version contains only minor changes and improvements such as dependency updates.
26.1.11
This version contains only minor changes and improvements such as dependency updates.
26.1.10
This version contains only minor changes and improvements such as dependency updates.
26.1.9
This version contains only minor changes and improvements such as dependency updates.
26.1.8
This version contains only minor changes and improvements such as dependency updates.
v26.1.7
Switched to better-sqlite3
We have replaced the unmaintained sqlite3 dependency with better-sqlite3 to ensure ongoing maintenance and stability.
Breaking changes
Turso is no longer supported due to the removal of the sqlite3 library.
Identity Federation: Support for including OIDC tokens in SAML responses
When creating an Identity Federation app, you can now include OIDC tokens in the SAML Response by setting the
includeOidcTokensInAssertion attribute. This allows downstream applications to access the original OIDC tokens issued by the
provider when using SAML federation.
26.1.6
This version contains only minor changes and improvements such as dependency updates.
26.1.5
This version contains only minor changes and improvements such as dependency updates.
26.1.4
This version contains only minor changes and improvements such as dependency updates.
26.1.3
This version contains only minor changes and improvements such as dependency updates.
v26.1.2
Identity Federation: SAML Response expiry is now configurable
When creating an Identity Federation app, you can now control how long a SAML Response remains valid by setting the ttlInMinutes attribute. The default expiry remains 10 minutes.
26.1.1
This version contains only minor changes and improvements such as dependency updates.
26.1.0
This version contains only minor changes and improvements such as dependency updates.
25.4.12
This version contains only minor changes and improvements such as dependency updates.
25.4.11
This version contains only minor changes and improvements such as dependency updates.
25.4.10
This version contains only minor changes and improvements such as dependency updates.
25.4.9
This version contains only minor changes and improvements such as dependency updates.
25.4.8
This version contains only minor changes and improvements such as dependency updates.
25.4.7
This version contains only minor changes and improvements such as dependency updates.
25.4.6
This version contains only minor changes and improvements such as dependency updates.
25.4.5
This version contains only minor changes and improvements such as dependency updates.
25.4.4
This version contains only minor changes and improvements such as dependency updates.
25.4.3
This version contains only minor changes and improvements such as dependency updates.
25.4.2
This version contains only minor changes and improvements such as dependency updates.
25.4.1
This version contains only minor changes and improvements such as dependency updates.
25.4.0
This version contains only minor changes and improvements such as dependency updates.
25.3.9
This version contains only minor changes and improvements such as dependency updates.
25.3.8
This version contains only minor changes and improvements such as dependency updates.
25.3.7
This version contains only minor changes and improvements such as dependency updates.
25.3.6
This version contains only minor changes and improvements such as dependency updates.
25.3.5
This version contains only minor changes and improvements such as dependency updates.
25.3.4
This version contains only minor changes and improvements such as dependency updates.
25.3.3
This version contains only minor changes and improvements such as dependency updates.
2025-07-28
Tag: c2370d3c35c060459b46f1c77b2241a23a1b04eb
No specific upgrade steps are required for this release. The Ory Polis OEL image is now available in the Ory Enterprise Docker Registry.