Skip to content

Week 36 · 31 August – 6 September 2026 ​

You can now move your users out of Auth0 in bulk: the users-import job API is implemented, bcrypt hashes included, and a migration script written against Auth0 works by changing only the base URL. The token pipeline got stricter — custom claims written by hooks can no longer overwrite the claims the server computes, and a client assertion can only be spent once — and one of those changes needs your attention if a hook of yours writes azp. Operators running the kysely adapter against MySQL or PlanetScale should upgrade: the background cleanup of sessions and codes had been silently failing for months, and now actually deletes. Audit events grew up — they carry the entity's before and after state, they stop leaking log-stream credentials, and they can be archived to R2. And the drizzle adapter has a new migration to apply.

Import your users in bulk ​

POST /api/v2/jobs/users-imports accepts an Auth0-format user file and imports it as a background job, with GET /api/v2/jobs/{id} for progress and GET /api/v2/jobs/{id}/errors for the per-row failures. The auth0 npm client's jobs.importUsers(), jobs.get() and jobs.errors() work unmodified, so an existing migration script only needs a new base URL. Until now the options for moving an existing user base were one POST /users at a time, a non-Auth0 JSONL format, or lazy migration.

bcrypt password hashes are imported and verified at login; both password_hash and custom_password_hash are accepted. Other algorithms are rejected per row with UNSUPPORTED_HASH_ALGORITHM — storing a hash the login path cannot verify would create a user who can never sign in — so a mixed export still imports its bcrypt majority, and the rest fall through to password reset or lazy migration.

Durability lives in the database, not an execution engine: every row in the file is staged before the request returns, and chunks commit their outcomes before advancing. A driver that dies mid-run loses at most the chunk in flight, and the next run resumes from the last committed chunk — the same code path and the same guarantees on Node and on Cloudflare Workers, no queue or workflow engine involved. A job whose chunk stops making progress halts with a warning and is picked up by a later run, rather than re-applying the same writes forever.

Hooks can no longer overwrite the token's own claims ​

One thing changes behaviour, and one deployment class must act. Three different lists used to govern which claim names a hook could set — one for the credentials-exchange hook API, one for service tokens, none at all for /userinfo, where a hook could replace sub in the response. There is now one reserved set per payload kind, enforced on every write path: names the server computes (sub, scope, azp, tenant_id, nonce, at_hash and the rest) cannot be set by tenant code on access tokens, ID tokens, service tokens or the /userinfo response.

A colliding claim is now dropped with a warning in the tenant log stream instead of failing the exchange — previously the seven JWT-spec names threw, so a hook that picks an unlucky name now degrades to "the claim isn't there" rather than breaking logins.

The case that needs action: the server never emits azp on its own, so if one of your hooks writes it, reserving the name deletes the claim from every token your services were reading it from. The transitional escape hatch is:

ts
init({ unsafeAllowAzpCustomClaim: true });

which releases azp — and only azp — while you move the downstream services onto a claim the hook owns. It is named unsafe on purpose; enable it only where the hooks writing azp are first-party code.

Client assertions (private_key_jwt and client_secret_jwt) are also tightened: an assertion's jti is spent at the token endpoint, so replaying one returns invalid_client, and an assertion's lifetime is bounded — more than 300 seconds between iat and exp (or an exp further out than that, if iat is omitted) is rejected. CLIENT_ASSERTION_MAX_LIFETIME_SECONDS and CLIENT_ASSERTION_LEEWAY_SECONDS adjust the bounds. A client that mints a fresh assertion per request — what the spec intends — notices nothing.

For operators on MySQL: cleanup drains again ​

If you run the kysely adapter against MySQL or PlanetScale, the background cleanup of expired sessions and codes has likely been doing nothing, and looking successful while at it. Three compounding problems, all fixed:

  • The session sweep deleted with expires_at < now OR idle_expires_at < now in one statement. MySQL will not use an index across that OR, so on a large table the statement full-scanned, exceeded PlanetScale's 20-second limit and was killed — silently, because the sweep swallowed errors. The predicates now run as separate, index-friendly statements, and a failed sweep warns instead of logging success.
  • The three session tables were drained strictly in sequence, so a backlog in the first consumed the whole per-invocation budget and the later tables never got a single delete. In the production audit that prompted the fix, login_sessions was 1.8 million rows and six months stale. The tables are now swept round-robin under an explicit batch budget, so no table starves the others.
  • The codes sweep deleted its whole backlog in one unbounded statement, which the same per-statement limits reject. It now deletes in 50,000-row passes, the way the action-executions sweep already did.

Nothing to configure: upgrade the adapter and the next runs chip away at the backlog until it is gone. A run that exhausts its budget says so, naming the tables that did not drain.

Action execution records: complete, and bounded ​

Two defaults change on upgrade. The console output an action writes is now persisted up to 256 characters per execution — Auth0's documented limit — with an explicit truncation marker, instead of verbatim. And execution records are kept for 10 days instead of 30, matching Auth0 again; pass actionExecutionsRetentionDays: 30 to runRetention if you want the old window. The prompting case was a production table where persisted console.log output had grown to 1.7 GB, written on every token exchange. Worth knowing either way: anything an action logs lands in a database table, so never log secrets or PII from hook code.

The records are also more complete. A deployment using environment hooks (hooks.onExecutePostLogin passed to init()) got no execution record and no execution_id on the login log at all — only database-stored actions did. Env hooks now produce the same records, including when the hook redirects or throws. And the successful-login log no longer loses its request snapshot (method, path, query) on exactly the logins where an action ran, which were the ones most likely to be debugged.

Audit events carry the before and after ​

Audit events for configuration changes now record entity state across the board: branding, prompts, roles, hooks, log streams, email templates, forms, flows, custom domains, actions, trigger bindings and migration sources. An update carries the entity before and after plus a field-level diff; a delete carries the removed row, so what was destroyed is recoverable from the audit trail. Action state keeps its source code readable and its secret names visible while stripping the secret values.

One security fix in the same area, found by review: redaction only swept top-level fields, so a log stream's sink credentials — the HTTP authorization header, a Datadog API key, a Splunk token — reached every audit destination in clear text on create and update. Redaction now recurses into nested objects and covers those fields. If you archive audit events externally, events recorded before this week may hold those credentials; rotate them if that worries you.

The outbox is operable now ​

Three additions for anyone running the transactional outbox:

  • Metrics. The relay emits typed metric records — events processed, events dead-lettered, retry backoff observations, each carrying tenant, event type and destination — to a sink you pass as init({ outbox: { metrics } }). On Cloudflare, the adapter ships a ready-made Analytics Engine sink. Until now a dead-lettering outbox was invisible outside log scraping.

  • Bulk retry. After a destination outage, replay the dead-letter backlog in one call instead of one POST per event:

    http
    POST /api/v2/failed-events/bulk-retry

    with up to 100 ids; the response reports replayed and not_found per id, so a batch does not abort because one event was already replayed.

  • An archive destination. init({ outbox: { pipeline: { endpoint, token } } }) streams audit events to a Cloudflare Pipelines endpoint, landing them in R2 as an Iceberg table with the common columns (tenant, event type, actor, target) promoted for querying. With no pipeline config, nothing changes.

Client grants: unique, ordered, and honest about scopes ​

An adapter migration is required. The drizzle schema never enforced one client grant per client and audience, so a second grant for the same pair could silently shadow the first — whichever the lookup found first won, with no error. A new migration deduplicates existing rows (keeping the newest per pair) and adds the unique index kysely always had. Apply your drizzle migrations when you upgrade; new grant writes for an existing pair are rejected afterwards, as they always were on kysely.

Grant listings also order the same way on both adapters now — newest first — where drizzle's unsorted listing used to come back oldest first, so the same tenant data paged differently depending on the adapter.

And a misconfiguration that used to be invisible is now visible twice over: a grant scope that the resource server does not define is silently dropped from issued tokens — the grant shows five scopes, the token carries three, and every layer looks correct in isolation. The server now logs a warning naming the dropped scopes, and the admin UI's client-grants tab flags undefined scopes in red, both in the table and in the edit dialog.

Actions management tightened ​

One request that used to succeed now fails. Binding an action to a trigger the system cannot dispatch — post-user-update, or a typo — used to return 200 and persist a binding that never fires. It now returns 400, which is also what Auth0 does. Any binding you created that way never ran, so nothing that worked stops working.

Action secret values no longer appear in the management API's OpenAPI document: responses were already redacted to secret names at runtime, but the published schema still advertised a value field, and nothing stopped a future response path from forgetting to redact. The response schema now permits names only.

In the admin UI, the action edit form shows the action's trigger again and lets you move an action to a different trigger — previously the trigger was invisible there and unchangeable. Paginating actions without include_totals also reports a real page window now instead of zeros.

A padded email is the same email ​

Autofill and mobile keyboards routinely append a trailing space to an email address, and three universal-login screens treated that padded string as a different identifier. On the login identifier screen, a padded address missed the account and silently routed the person to an email-code fallback instead of their password. On the change-email screen it was worse: the "is this address already taken?" check ran against the padded string, found nothing, and let someone claim a mailbox another user already owns — and that guard turned out never to have fired at all, padded or not, due to a paging bug. The change-email verification step then carried the padded value through into the redirect and onto every linked account. All three now trim before matching, and the already-taken guard works.

Also ​

  • Adapters — deleting an unknown organization through the management API now returns 404 on the kysely adapter instead of a false 200. The AWS DynamoDB adapter's unimplemented actions endpoints throw a typed FeatureNotSupportedError (exported from the adapter interfaces) so a host can answer 501 instead of a generic 500.
  • Management API — the CORS preflight and the actual response are built from the same code, so they can no longer disagree, and Vary: Origin is sent once instead of twice.
  • create-authhero — the aws-sst template scaffolds against SST v4, and its commented custom-domain example is now one that actually works.
  • Docs — a new end-to-end first-login walkthrough (setup wizard, admin dashboard, then the same login driven by hand with /authorize, /oauth/token and /userinfo); the troubleshooting and database-integration pages are real content instead of placeholders; the testing guide describes commands that exist; ten existing chapters (the adapter pages, the CLI, the admin dashboard) are reachable from the sidebar instead of only by inbound link; and the stale references to long-removed demo apps are gone from the README, the docs and the LLM-facing summaries.

Released ​

PackageSpan
authhero9.10.0 → 9.11.1
@authhero/adapter-interfaces4.12.0 → 4.13.0
@authhero/drizzle1.6.2 → 1.7.0
@authhero/kysely-adapter12.6.2 → 12.7.0
@authhero/aws-adapter1.3.2 → 1.3.3
@authhero/cloudflare-adapter3.1.0 → 3.1.1
@authhero/multi-tenancy15.0.1 → 15.0.2
@authhero/proxy0.10.11 → 0.10.12
@authhero/saml0.5.10 → 0.5.11
@authhero/widget0.38.7 → 0.38.8
@authhero/admin0.20.3 → 0.20.4
create-authhero0.50.1

No package is new this week. The drizzle minor is the client-grants uniqueness migration; the kysely minor carries the cleanup fixes and the bulk-import staging; the authhero spans cover everything above.

Dual-licensed: AGPL-3.0-only or commercial license.