Week 35 · 24–30 August 2026
Refresh tokens now belong to the session that minted them: revoking or deleting a session through the management API, or blocking a user, revokes every refresh token issued under it, which is what Auth0 documents and what the endpoints had never done. You can list and revoke a user's refresh tokens from the API and from a new tab in the admin UI, and the per-client refresh-token lifetime settings that were editable but ignored now decide when a token expires. SAML certificates can be renewed, and rotated with a staging window, from the API and the console instead of by editing the database. Two security fixes to know about: a quoted value in a q search filter could widen the match to other users' rows, and the signing-keys listing was returning private key material. And if you run a large kysely database, keep logs in Analytics Engine, or front AuthHero with the proxy, there is an upgrade step for you under For operators.
Refresh tokens belong to a session
One thing changes behaviour, and custom adapters must add a method.DELETE /api/v2/sessions/{id}, POST /api/v2/sessions/{id}/revoke and blocking a user now revoke the refresh tokens issued under the session. Until now they updated the session row and sent back-channel logout, and the refresh grant checked only the token's own revoked_at, so after an admin revoked a session its refresh tokens kept minting access tokens until they expired on their own. The user-block path was worse: it found tokens through the session's original login transaction, which is never updated when a session is reused across clients, so every token minted at a later re-authorization survived a block. Blocking a user also re-runs the cascade over sessions revoked before this release, so the tokens they left behind are cleared too.
The rule this follows: revocation couples, lifetime does not. A session that expires naturally, or that cleanup deletes past its grace window, does not touch refresh tokens; a refresh token is expected to outlive its session, as in Auth0. The logout endpoints (/v2/logout, /oidc/logout) do not cascade either.
Underneath, every refresh-token row now carries its session_id and the facts of the login that produced it (organization, connection, strategy), and the refresh grant reads them off the token instead of the login session. That fixes a silent failure: login sessions are short-lived and routinely deleted, so an exchange on an older token quietly resolved those fields to nothing, on both the success and the failure-log path. The adapter interface gains refreshTokens.revokeBySession, implemented for kysely, drizzle and DynamoDB; a custom adapter has to add it.
Two smaller fixes in the same area. The passwordless OTP grant at /oauth/token now issues a refresh token when offline_access is in scope (it never did, whatever you asked for at /passwordless/start) and accepts scope and audience on the token request, as Auth0's does. And the cross-origin flow (/co/authenticate followed by /authorize?login_ticket=…) now carries the scope from /authorize into the code exchange; before, access tokens came back with an empty scope and offline_access never produced a refresh token, because nothing in the chain persisted it.
List and revoke a user's refresh tokens
GET /api/v2/users/{user_id}/refresh-tokens lists a user's refresh tokens and DELETE on the same path revokes them all, matching Auth0's paths and body shape. The list accepts both Auth0's checkpoint paging (from/take, answering { tokens, next }) and the offset style the rest of the API uses, so an Auth0 SDK and the admin UI both work against it. Responses carry Auth0's fields only: token secrets and rotation bookkeeping never leave the server, and session_id is included. Single-token routes are also mounted at /api/v2/refresh-tokens/{id}; the underscore path stays as an alias. One deliberate deviation from Auth0: the bulk delete soft-revokes rather than removing rows, so the tab and the audit trail still show what was invalidated and when.
The admin UI's user page has a Refresh Tokens tab with per-token revoke and a revoke-all button. On the way, the drizzle listing's include_totals count turned out to ignore the query filter and report the tenant-wide row count; fixed.
Per-client refresh-token lifetimes apply
If you set these fields on a client, behaviour changes on upgrade. A client's refresh_token settings (expiration_type, token_lifetime, idle_token_lifetime, infinite_token_lifetime, infinite_idle_token_lifetime) round-tripped through the API and were editable in the admin UI, but nothing read them: refresh-token expiry was derived from the tenant's session lifetimes, which conflates two things Auth0 keeps apart and made a non-expiring token for a native or mobile client impossible to express. They are honoured now, at mint, on rotation, and on the non-rotating sliding window. Precedence, for the absolute and idle windows independently: expiration_type: "non-expiring", then the infinite flag, then the client's lifetime in seconds, then the tenant's session lifetime in hours. A client with nothing set sees no change.
PATCH /api/v2/clients/{id}
{ "refresh_token": { "token_lifetime": 2592000, "idle_token_lifetime": 1209600 } }Two behaviours worth knowing: refreshing slides the idle window but never extends the absolute expiry, and a token minted without an idle window is not given one later. The one exception is a client switched to non-expiring, which drops the expiries its existing tokens inherited rather than leaving them bounded forever.
SAML certificates renew and rotate
The certificate that signs SAML assertions could not be replaced without editing the database: every signing-key route was hard-wired to JWT keys, and the console never touched the SAML bucket. The prompting case was a certificate that expired on a live integration. Now:
- Every signing-key endpoint takes
?type=saml_encryption. The default staysjwt_signing, so existing callers are unaffected. POST /api/v2/keys/signing/{kid}/renewre-issues a certificate over the existing key pair. The public key andkidsurvive, so a service provider that validates against the key it already holds needs nothing; only one that pinned the certificate bytes needs the new file.POST /api/v2/keys/signing/rotatetakesactivate_in_days,grace_daysandvalidity_days. A staged key is published at once, in JWKS and as an extraKeyDescriptorin the SAML metadata, but does not sign until it activates. That window is what lets you deliver a certificate to a service provider, which cannot fetch one on its own, without a single failed login.- The console's Signing Keys screen splits into JWT and SAML tabs, with an expiry column (red inside 60 days, flagged once expired), a scope column, and a certificate dialog carrying the PEM and both fingerprints.
SAML certificates now default to five years, and get a real subject instead of CN=undefined when ORGANIZATION_NAME is unset. SAML keys are always tenant-scoped, with the shared key as the fallback, so a certificate can belong to the tenant whose service providers pin it.
Three things change behaviour. GET /api/v2/keys/signing no longer returns pkcs7: private key material was going to anyone holding read:signing_keys. Keys inherited from the control plane are read-only for a tenant: rotate, renew and revoke on them return 403, where before any tenant with update:signing_keys could revoke the key every other tenant was verifying against. Single-tenant deployments manage their keys exactly as before. And during a rotation's grace period the SAML signing path used to take the first row of an unsorted list, so the outgoing certificate had an even chance of signing the assertion; it now resolves the current key.
A quoted search value can no longer widen the match
A security fix, with one semantic change. The q filter on list endpoints (Lucene syntax) split on OR before it honoured quotes, so a quoted value containing OR was parsed as query syntax: user_id:"attacker OR user_id:victim OR x" matched the victim. The tokenizer now runs first, so a quoted value is one literal and an escaped quote cannot close its own quoting. Every place the server interpolates a value into q (email, username, phone and linked-account lookups, organization names, SCIM, dynamic client registration, the multi-tenancy sync hooks, the admin UI) goes through a new escapeLuceneValue helper, exported from the adapter interfaces for your own code to use.
The semantic change: clauses inside an OR group are conjoined, so a b OR c reads (a AND b) OR c instead of b being swallowed into the first clause's value. field:a OR field:b behaves as before.
Account linking works from the admin UI again
Linking two users returned 400 for every pair. POST /api/v2/users/{id}/identities accepts { provider, user_id }, where Auth0's user_id is the secondary's id without its provider prefix, and the handler looked the bare id up verbatim. It rebuilds the full id now; an already-prefixed value still resolves. Two adjacent bugs of the same kind: identifiers that embed pipes of their own (samlp|okta|jane) were split on every pipe, so identities[].user_id named an id belonging to nobody, and creating a user with such an id stored a different one than you sent; and the drizzle adapter reported the full provider|id in identities[].user_id where Auth0 and kysely report the bare id, so the admin UI's Unlink button re-prefixed it, matched no row, and returned 200 having unlinked nothing. On drizzle, identities[].user_id is now the bare id, matching Auth0.
Searching users by email is indexed
A bare email address in q used to run as a leading-wildcard LIKE across email, name and phone, and then again as a count for include_totals, reading every user in the tenant twice; the admin UI's lookup took seconds. A complete email address is now resolved as an equality on the email column, served by the existing unique index, and the totals count runs alongside the page query instead of after it. Partial terms keep substring semantics. Both SQL adapters, and the admin UI's Link user dialog sends a field-scoped query when the input is a full address, so it gets the indexed path even against a server that has not picked up the adapter fix yet.
Emails are trimmed before they are stored or matched
Email normalization lowercased but never trimmed, so an address with a stray space was stored verbatim as a separate identifier, producing two accounts for the same person on one connection, and auto-linking skipped the pair because it keyed on the untrimmed value. Every write and lookup path now trims first, including SCIM provisioning, lazy migration from Auth0, passwordless start, signup, change-password, tickets and the account-linking reads, so a padded row that already exists becomes linkable. Operators: existing padded rows still need a repair pass. Rows in users and user_identities whose email differs from its trimmed form have to be merged into their trimmed twin, or trimmed in place where no twin exists.
The grant-type check runs before the grant
One error code changes. The client grant_types allowlist at /oauth/token was checked after the grant had executed, so a passwordless OTP request from a client not registered for that grant was refused with unauthorized_client only after its one-time code had been marked used and, since 9.9.0, a session and a refresh token had been minted and orphaned. The check now runs first, before any grant flow, and the client is resolved once and handed to the flow, which also means a URL-based client id's metadata document is fetched exactly once. A client sending a wrong secret and a disallowed grant now gets unauthorized_client where it used to get invalid_client.
For operators
- A large kysely database has a backfill to run by hand. The migration that writes
session_idonto existing refresh tokens now counts them first and runs in-process only below a threshold that fits inside one Worker invocation; a larger deployment gets a logged warning naming a bulk SQL script under the adapter'smigrate/data-migrations/directory, to run in batches until it reports zero rows. Nothing breaks in the meantime, since the grant falls back to the login session whensession_idis empty, but the backfill must finish before you enablelogin_sessionsretention, or the facts are unrecoverable. It covers live tokens only; rotated, revoked and expired rows are skipped. - Analytics Engine logs: forward the
statsadapter./api/v2/stats/dailyreturned zeros for every day when logs live in Analytics Engine, so the admin dashboard's daily logins and signups graphs read 0. Two causes: the adapter re-sliced an already-normalized date intoNaNbounds, and the Cloudflare adapter never returned astatsadapter, so the SQL one kept answering from alogstable nothing writes to any more. Both fixed, and the docs integration sample now spreadslogs,analyticsandstatstogether; if yours forwards onlylogs, add the other two. - Custom domains reconcile on a cron. A domain's state only refreshed when someone opened its detail page, so a hostname that finished validation at the edge stayed
pending, and certificate-renewal failures were invisible.syncCustomDomains, exported from the Cloudflare adapter, pages the zone and writes back what changed; the control-plane template runs it every five minutes beside the nightly retention job. It never deletes. Writing it exposed that on the drizzle adapter a custom domain could never be markedreadyat all: the verification payload was written as a raw object, SQLite rejected the statement, and the status write went down with it, silently, on every read. - Proxy: an infinite redirect on
/authorize/resume. With the proxy'srewrite_locationin front of a control plane, the deliberate cross-host hop back to the authorization host was rewritten onto the vanity host, failed the host check there, and looped. The server now marks its cross-host redirects with anx-authhero-preserve-locationheader, and the proxy leaves those alone and strips the marker. Upgradeauthheroand the proxy together; same-host redirects are untouched. - A replayed outbox event was silently skipped. Dead-lettering left the relay's claim on the event and replay did not clear it, so
POST /api/v2/failed-events/{id}/retrywithin the claim's lease window looked like it did nothing. Both SQL adapters release the claim on replay. An end-to-end test of the outage-to-recovery cycle found it.
Also
- Test login shows the tokens. The admin UI's Test login link lands on
/u2/info, which now redeems the authorization code server-side and shows the decoded ID-token claims, the token details, and copy buttons for the ID, access and refresh tokens. A code issued for another redirect target is refused and left redeemable at its real target, so an intercepted code cannot be cashed here. - Default email templates render through the unified
react-emailpackage now: section padding moves to where Outlook honours it, the preview text also sets a title, and the body inheritsdirandlang. The Liquid placeholders are unchanged, so tenant branding resolves as before. - Docs. This changelog exists, with the completeness gate described on its index page. The API reference covers the refresh-token routes, the typed signing-key routes and the passwordless OTP grant; the tokens and applications pages document the per-client lifetimes; the schema and session-management pages describe the refresh-token session link and which paths cascade; the SAML configuration page has a certificate-lifecycle section and
ORGANIZATION_NAMEis correctly marked optional; the Cloudflare adapter pages forward all three log-derived adapters; and the Microsoft troubleshooting entry for "Invalid code verifier" now names the actual cause, an expired login code.
Released
| Package | Span |
|---|---|
authhero | 9.7.0 → 9.9.1 |
@authhero/adapter-interfaces | 4.9.0 → 4.11.0 |
@authhero/drizzle | 1.5.0 → 1.6.1 |
@authhero/kysely-adapter | 12.5.0 → 12.6.1 |
@authhero/aws-adapter | 1.2.0 → 1.3.1 |
@authhero/cloudflare-adapter | 3.0.9 → 3.0.12 |
@authhero/proxy | 0.10.8 → 0.10.10 |
@authhero/saml | 0.5.7 → 0.5.9 |
@authhero/widget | 0.38.4 → 0.38.6 |
@authhero/admin | 0.19.3 → 0.20.2 |
No package is new this week. The first authhero minor is the SAML certificate work and the refresh-token session ownership; the second is the passwordless OTP refresh token. The adapter and interface minors are the refresh-token columns and the session revoke cascade, then the Lucene tokenizer and escaping helpers.