- DATE:
- AUTHOR:
- Ory Team
Ory Network,Ory Hydra, Ory Kratos, Ory Keto, Ory Polis v26.3.13 released
Ory Network
Temporary workaround for firewalls that reject post-quantum TLS handshakes
Ory services began offering the ML-DSA post-quantum signature algorithms in outgoing TLS 1.3 handshakes. Some firewalls in front of customer endpoints reset connections that offer them, even though TLS requires unrecognised algorithms to be ignored (RFC 8446, section 9.3). Affected customers saw social sign-in fail while fetching the OpenID Connect discovery document.
Ory services no longer offer these algorithms, so those connections succeed again with no change on your side.
This is a temporary measure. Ory will offer the ML-DSA algorithms again on 24 October 2026, in line with the wider TLS ecosystem. If your firewall or network appliance resets TLS handshakes that offer unrecognised signature algorithms, ask your vendor for a fix or configuration change before that date. You can verify readiness with any TLS client built on Go 1.27 or later.
Ory Hydra
Hydra no longer generates a self-signed TLS certificate
When TLS was enabled and no certificate was configured, Hydra generated a
self-signed certificate at startup, stored it in the hydra.https-tls JSON Web
Key Set, and served it. Hydra now starts only with a certificate you provide.
Enabling TLS without one fails at startup with an error naming the settings to
set.
What you must do
You are affected if you set serve.tls.enabled (SERVE_TLS_ENABLED) to true
without setting a certificate. Choose one:
Configure a certificate and private key, either as file paths (
serve.tls.cert.pathandserve.tls.key.path) or base64-encoded (serve.tls.cert.base64andserve.tls.key.base64). Hydra reloads the certificate when the file changes.Disable
serve.tls.enabledif a load balancer, ingress controller, or service mesh terminates TLS in front of Hydra. Hydra then serves HTTP.
Deployments that already configure a certificate, and deployments that leave TLS
disabled, are unaffected. Once Hydra starts, you can delete the now-unused key
set with hydra delete jwks hydra.https-tls.
Why
The generated certificate could not authenticate Hydra to anything, and generating it caused problems of its own:
The only name in it was
localhost, with no IP addresses, so no client that verifies hostnames could accept it for a real deployment.It was valid for 31 days and was never renewed. Because it was stored in the database and reused on every restart, any installation older than a month has been serving an expired certificate. Only peers that skip verification, such as load balancers that do not validate backend certificates, kept working — which is why this change can surface as a startup failure on an installation that appeared healthy.
It was a certificate authority certificate that could sign certificates. Adding it to a trust store, as our documentation used to suggest, made Hydra's database a trust anchor for any name.
Its private key was stored alongside your signing keys and could be read through the admin API.
Replicas starting at the same time against a fresh database each generated their own certificate, so nodes behind the same load balancer served different ones. Also, a replica that lost the race writing its certificate back could fail to start.
Generating a certificate nothing can verify is not something Hydra should do on your behalf. A deployment that needs a self-signed certificate is better served by one you create, distribute to the clients that must trust it, and rotate on your own schedule.
Hydra no longer supports the TLS termination allow-list
The serve.tls.allow_termination_from setting is gone. A configuration file
that still sets it fails validation at startup, with an error naming the key.
Set through SERVE_TLS_ALLOW_TERMINATION_FROM, it is ignored.
What you must do
You are affected if you set serve.tls.allow_termination_from in a
configuration file. Remove the setting. Nothing else about how Hydra serves
traffic changes.
If a load balancer, ingress controller, or service mesh terminates TLS in front
of Hydra, leave serve.tls.enabled unset. Hydra then serves HTTP, and the proxy
is responsible for HTTPS. Make sure the proxy is the only way to reach Hydra.
Why
The setting was meant to name the CIDR ranges allowed to reach Hydra over plain
HTTP and to require an X-Forwarded-Proto: https header from them. It never
took effect. Hydra installed the check only when serve.tls.enabled was true,
and a server that speaks TLS never receives the plain HTTP requests the check
was written to reject. The deployments the setting was written for — the ones
that terminate TLS at a proxy and therefore leave serve.tls.enabled unset —
never ran it.
Deciding which callers may reach Hydra over plain HTTP belongs to the proxy or the network in front of it, which is where deployments that need it already enforce it.
Reusing a device code revokes its access and refresh tokens
Presenting a device_code that has already been exchanged now revokes the
access and refresh tokens issued from that code, including the ones the device
obtained by refreshing. This is the handling
RFC 6749 section 4.1.2
defines for a reused authorization code, applied to device codes. The reused
request itself is still rejected with invalid_grant.
Ory can't tell whether the first or the second request came from your device, so
it assumes the device_code leaked and withdraws the tokens issued under it.
Only the client the code was issued to can trigger this: a request from another
client_id is rejected without touching the tokens. Reuse is only recognized
while the consumed device_code is still on record; a code presented after that
is rejected as unknown and revokes nothing.
Breaking change
Devices that retry the token request must stop reusing a device_code. A
token request that timed out or whose response was lost may still have issued
tokens. Retrying with the same device_code now revokes them, leaving the
device without working credentials. Retry only after a failed request, treat
invalid_grant as final, and start a new device authorization request instead
of polling on.
Cleaning up consumed device codes
Consumed device codes are retained briefly so reuse can be detected, then
collected by hydra janitor --tokens. If you already run the janitor on a
schedule, no action is required.
OAuth2 client key sets refresh at most once every 15 seconds
When a client authenticates with private_key_jwt or sends a signed request
object, Ory now starts at most one refresh attempt every 15 seconds per
jwks_uri for the key set downloaded from the client's jwks_uri, even when
the assertion names an unknown key ID. A failed attempt may be retried by the
HTTP client within that refresh's timeout. A newly published signing key can
stay unknown to Ory for up to 15 seconds after the previous refresh. Publish a
new key at jwks_uri at least 15 seconds before signing assertions with it,
and keep the retired key published while assertions signed with it can still
arrive.
If refreshing the key set fails, Ory keeps serving the previously downloaded
keys for up to one hour past their regular three-minute lifetime before
requests fail with the refresh error. Publishing an empty key set
({"keys": []}) revokes every key: Ory adopts it on the next refresh and
rejects all assertions immediately after.
This is now available on Ory Network, for the Ory Enterprise License, and will be part of the next Ory Open Source release.
Ory Kratos
Creating an identity with an unknown schema ID now fails with 400
Sending a schema_id that your project does not define to
POST /admin/identities, PUT /admin/identities/{id}, or
PATCH /admin/identities/{id} now returns 400 Bad Request with the reason
Unable to find JSON Schema ID: <id>. It previously returned
500 Internal Server Error.
You are affected if your integration treats these endpoints' errors by status
class — for example by retrying on 5xx. Requests carrying an unknown schema ID
were never going to succeed, and are now reported as the client error they are,
so they stop being retried. The response for a schema ID your project does
define is unchanged.
Listing identities by ids returns every match in one response
GET /admin/identities?ids=... is a bulk lookup, not a listing. It now returns
every identity you asked for in one response and sends no pagination headers.
Self-hosted deployments previously paginated this request at 100 ids by
default, so a caller that asked for 300 ids and did not follow the Link
header silently received 100 identities. Pass at most 500 ids per request, as
before. If you read X-Total-Count or follow Link on this request, drop that
handling — the response is complete as it stands.
On the Ory Network, these identities now come back in the same shape as
GET /admin/identities/{id}: each carries its schema_url, which this filter
previously left empty, and legacy credentials are upgraded to their current
representation. Identities are also validated, so a request that matches an
identity whose schema_id no longer resolves to a configured schema now fails
instead of returning that identity with an empty schema_url.
Raise a login's AAL from the after web_hook response
A login after web_hook with response.parse enabled can now return
{"required_aal": "aal2"} to require step-up for that login. One response can
both update the identity and raise the AAL. Users who do not yet have a second
factor are routed to enrollment rather than let through.
This is off by default. Enable it with the new feature flag
feature_flags.webhook_response_directives, or on Ory Network by setting
kratos_feature_flags_webhook_response_directives on the project. While the
flag is off, a required_aal field in a web_hook response is ignored exactly as
it is today, so existing hooks are unchanged. Once it is on, a required_aal
value other than "aal2" fails the login rather than being silently dropped; on
a web_hook at another position the directive is logged and ignored.
Route generic social sign-in with an organization-claimed email to the organization's SSO
Signing in with a generic social sign-in provider (for example Google) using an email address whose domain is claimed by an organization no longer fails with an error. Kratos now responds with a login flow scoped to the matched organization that contains only the organization's SSO provider buttons and an informational message (ID 1010026), matching the identifier-first behavior. This covers both existing accounts and first-time sign-ins.
SCIM group pushes with large memberships are batched
Pushing a group with a large membership through SCIM no longer times out. A
PUT, PATCH, or DELETE on /scim/{client}/v2/Groups/{id} used to process
members one at a time, which past a few hundred members exceeded the request
budget and rolled the write back, leaving the identity provider with a group
that had been emptied. Members are now processed in batches. Very large pushes
can still exceed the request budget, especially on multi-region deployments.
Error responses on these endpoints changed:
A transient failure while processing a member now answers
500 Internal Server Errorinstead of409 Conflict. A409tells an identity provider to stop retrying, so pushes that would have succeeded on a retry were abandoned. Only a genuine unique-attribute collision still answers409withscimType: uniqueness.A push that would change more than 12,000 members is rejected up front with
400 Bad RequestandscimType: tooMany, naming the group and the limit. Split the group into smaller ones to provision it.A push whose membership includes an identity that was not provisioned through SCIM answers
400 Bad RequestwithscimType: invalidValueand names the identities, instead of a generic500. Provision those identities through/Usersfirst.
Session-to-JWT key set hardening and load failures returning 400
When Ory converts a session to a JSON Web Token, a key set loaded from the
tokenize template's jwks_url at a file:// location is now cached for up
to 3 minutes instead of up to one hour, so a rotated key file is picked up
faster. If the file becomes unreadable, Ory keeps signing with the
previously loaded keys for up to one hour before requests fail, where
previously requests failed as soon as the cached keys expired. Key set
documents larger than 1 MiB are rejected.
A jwks_url whose key set cannot be loaded now fails the request with 400
Bad Request instead of 500 Internal Server Error, because it is a defect of
the selected tokenize template.
TOTP authenticator setup no longer breaks after a rejected settings submission
Enrolling a TOTP (authenticator app) two-factor method could fail permanently when the settings flow used a webhook that can interrupt the request. After the first interrupted submission, every retry on the same flow returned an internal server error, and the user could not finish setup without starting a new flow.
TOTP setup now stays usable after an interrupted submission: the user can submit again on the same flow and is given a fresh secret and QR code to scan when the previous one is no longer available.
This is now available on Ory Network, for the Ory Enterprise License, and will be part of the next Ory Open Source release.
Ory Keto
Permission checks answer correctly when branches meet or loop
A permission check could return the wrong answer — most often a denial or a
max_depth_exceeded failure for a subject who should have been allowed, and on
permissions that use !, a grant to a subject who should have been denied. Two
cases caused it:
Branches that meet. When two paths through your data led to the same group or role, the first path to reach it claimed it for the whole check. If that path then ran out of depth, the second path was dropped even though it would have found the subject. Nested groups reachable more than one way are enough to hit this.
Loops in your data. A relationship that leads back to where it started — a folder chain that eventually points at an ancestor, or two groups that contain each other — was followed until the depth limit stopped it, instead of being recognized as adding nothing.
Both now resolve correctly. A loop in your data is answered decisively rather than reported as too deep, and one branch can no longer suppress another.
If you worked around this by raising limit.max_read_depth, you can lower it
again. Checks over data whose branches converge are also considerably faster,
because each group or role is now resolved once per check rather than once per
path that reaches it.
Behavior changes
Checks that previously answered "allowed": false — or 403 on the endpoints
that signal a denial that way — or failed with 422 for the cases above now
return the correct answer, which may be an allow. If you built on one of those
denials, review it.
Checks also change the other way, which is the case to review first. A
permission written as "allowed unless denied" — an allow list combined with !
over a deny list — could be granted to a subject the deny list covers, whenever
another part of the same check had already walked the group that leads to that
deny list. The deny branch was dropped as already seen, that reported "not
denied", and ! turned it into a grant. Such a check now denies. Review any
permission that negates a deny list reached through a group or a subject-set
pointer: subjects who were allowed may no longer be, and some of them should
never have been.
A permission defined as the negation of itself, directly or through other
permissions — for example edit defined as "not view" while view is defined
as "not edit" — has no answer, and never had one. Such a check now reports
negation_cycle instead of failing after an internal step limit, and only that
part of the check is affected: if the permission can also be granted another
way, the check now answers from that instead of failing outright. This signals a
definition to fix in your permission model.
Reject Ory Permission Language that traverses a subject set
traverse visits the objects a relation holds, so it cannot be used on a
relation typed as SubjectSet<Namespace, "relation">. Saving such a schema now
fails.
Declare the relation as the namespace the traversal visits:
related: {
groups: Group[] // not SubjectSet<Group, "members">[]
}Schemas saved before this change keep working, and no permission check changes its answer.
This is now available on Ory Network, for the Ory Enterprise License, and will be part of the next Ory Open Source release.
Ory Polis
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.
This is now available for the Ory Enterprise License.
Ory Oathkeeper, Ory Elements, and Ory Terraform
No significant changes in this release.