Skip to Content

v0.1.97 — 18 Sep 2026

This release contains security fixes. Upgrade is recommended for every deployment. One of them changes behavior: if stored code in your deployment makes outbound requests and EXTENSION_ALLOWED_DOMAINS is not set, those requests now fail. See Stored code was able to reach any host.

Security — code stored in the database ran in the same realm as the server

Who is affected: Every deployment that lets anyone other than an administrator create custom services, cron jobs, or extensions.

Custom services, cron jobs, and extensions store JavaScript in the database, and the server compiled it with new Function(). That code ran in the same V8 realm as the host process. The constructor chain reached process and require:

this.constructor.constructor('return process')()

Blocking those names never closed that path. The permission needed is create permission on daas_custom_services, daas_cron_jobs, or daas_extensions. That is a collection permission, not admin access.

Stored code now compiles into a separate realm, where the constructor chain resolves to the sandbox’s own Function and to a process that is undefined. Built-in prototypes are frozen. Error stays writable, because extensions subclass it.

One risk remains, and the sandbox module records it. Service and context objects enter the sandbox by reference, so a value returned by a services.* call still carries host prototypes. Closing that needs every service call to cross a serialization boundary, which was evaluated and rejected as disproportionate for this service surface.


Security — saving a custom service ran its code

Who is affected: Every deployment. No activation was needed to trigger this.

The sandbox compiled with a script evaluation, which runs every top-level statement. Code that closed the generated wrapper early therefore ran during compilation. Validation compiles too, so saving a custom service ran the submitted code on the host. It ran before item-level permissions applied, and it ran for a draft that nobody activated. A for(;;) loop also blocked the worker for the five-second timeout on every save.

Compilation now parses the wrapper into a function and runs nothing. A breakout is a syntax error instead of an execution, and the submitted code sits behind a fixed return, so smuggled statements never run.

This also restores the globals that stored code had before the sandbox existed. They are URL, URLSearchParams, Buffer, crypto, TextEncoder, TextDecoder, atob, btoa, Headers, Blob, FormData, the abort types, structuredClone, and console. Without them, existing code compiled and saved, then failed on its first run. Timers, process, require, and raw fetch stay unavailable.


Security — stored code was able to reach any host when no allowlist was set

Who is affected: Deployments where stored code makes outbound requests. This one needs action.

safeFetch is the only outbound path for extensions, cron jobs, and custom services. It rejected a host only when the allowlist held at least one entry. An unset EXTENSION_ALLOWED_DOMAINS therefore meant “allow every host”, which is the opposite of the documented contract. Stored code was able to reach anything the container routes to, including internal services and the cloud metadata endpoint.

The guard now denies every host that is not localhost or explicitly allowlisted.

If stored code in your deployment makes outbound requests and EXTENSION_ALLOWED_DOMAINS is not set, those requests now fail. Set the variable to the hosts that code needs, as a comma-separated list:

EXTENSION_ALLOWED_DOMAINS=api.example.com,hooks.example.com

Security — write policies that restrict rows through a relation were ignored

Who is affected: Deployments with a permission filter that uses _has or a dot path into a related collection.

Such a filter was enforced on reads and dropped on writes. A read merges the filter before the relational conditions are extracted, so the database turns it into a join and non-matching rows stay invisible. Updates and deletes handed the same filter to a function that cannot express a condition on an embedded resource. The condition disappeared, and the statement ran with no row restriction. The count check did not catch it, because the rows matched and were written.

With an update policy of { approvals: { _has: true } } on a documents collection:

OperationBeforeNow
Read an unapproved documentNot visibleNot visible
Update or delete itAllowedRefused

_has on its own meant no restriction at all. Inside _and, the scalar half still applied, so the policy granted more than intended.

The database accepts an embedded condition only on a read. The fix resolves the filter to a set of primary keys with a read first. It then authorizes the write against that set.


Changed — Platform Studio pages now carry a locale prefix

Who is affected: Anyone who bookmarks or links to a Studio page.

Studio pages move to /[lang]/…, for example /en/users instead of /users. A request without a prefix redirects to the negotiated locale, so existing links keep working. Update a bookmark to the prefixed address to skip the redirect.

API paths do not change. /api/* is never prefixed.

The Studio also ships real Bahasa Indonesia translations. Before this release, the Indonesian dictionary was a copy of the English one.


Changed — optional shared cache in Valkey

Who is affected: Deployments that run more than one worker or container. The cache is off unless you turn it on.

Each worker keeps its own in-process caches, so a cold process reloads the same permission, authentication, and schema lookups from the database. A shared second-level cache in Valkey is now available. Set VALKEY_URL or VALKEY_SENTINEL_NODES to turn it on. Standalone, cluster, and sentinel connections are supported.

  • A request never waits on connecting.
  • After a failed command the cache pauses briefly, so an unresponsive Valkey costs one timeout for each window instead of one for each lookup.
  • An entry carries the generation that was current when its load started, so neither a stale entry nor a load that raced a change is served.
  • Keys carry the app version, so two releases never share entries during a deploy.

The bundled Valkey container in docker-compose.yml is opt-in now. It sits behind a valkey profile, and the app passes VALKEY_URL through instead of setting it. Before this release, the documented docker compose up -d path switched the cache on. An operator who started only the app service pointed it at a host that never came up.

See the environment variables reference for the full VALKEY_* table, and Docker for the profile.


Performance

Permission, authentication, and schema lookups share the cache. Policy sets, permission rows, primary roles, admin and delegate flags, and user status load through it. Field and relation metadata do too. Every writer of field, relation, or collection metadata now announces the change, and every worker clears its schema maps in response.

An optional session cache. VALKEY_SESSION_CACHE_TTL_MS is off by default. When set, the server remembers tokens that GoTrue accepted, so a repeat request skips the GoTrue round trip. Nothing is cached at login, and every hit still checks that the user is active and still applies the MFA check.

A session can be revoked outside this app, through the GoTrue admin API or by a password change in another client. With a session cache TTL set, such a session keeps working here for up to that TTL. Logout through the app, user suspension, and MFA enrollment take effect at once. Keep the TTL short, for example 60000.

An admin create no longer reads the row back. For an admin or internal caller, that read returned the inserted row unchanged except for masking. This applies on a collection with no alias fields and no read hook. The server now returns that row directly. Every other create reads back as before.

An extension reload recompiles only what changed. Every extension save reloaded all active extensions on every worker and recompiled all of them. Building a sandbox context costs about 0.3 ms for each extension, against 16 µs before. The loop also blocked the worker from start to finish. Compiled handlers are now kept by id, type, and source.


Fixed — presentation-only fields returned a 500

Who is affected: Anyone who requests a divider or notice field through ?fields=, or includes it with *.

A divider or a notice is stored as an alias field with no column. It carries none of the markers that identify a virtual field. Those fields were treated as regular columns and sent to the database, so the request answered 500 column X does not exist. The check now also reads the interface.


Fixed — a user with two filtered policies got a 500

Who is affected: Any user who holds two or more filtered policies on the same collection and action, or a policy written with nested groups.

A guard threw when a branch of an _or filter carried an object value. Those branches come from ordinary permission data: two filtered policies are combined as _or, and the Studio policy editor produces nested groups. The error surfaced as 500 on every list, read, update, and delete of that collection for that user.

On create and update, the row was written before the read-back failed, so a retry can create a duplicate row. Check for duplicate rows created by a retried save on a collection where a user saw this error.

The unsupported branch is now skipped with a warning.


Fixed — other

  • Column defaults are serialized according to the column type. Every object and array default was JSON-serialized, whatever the column was. A csv default of ['a','b'] became '["a","b"]', which the reader splits on commas and returns as ['["a"', '"b"]']. The numeric and boolean branches also missed the ALTER path, so an array default on an integer column emitted invalid DDL instead of being rejected.
  • Bulk updates returned numeric keys in the wrong order. updateMany sorted primary keys as strings, so [2, 10, 1] came back as [1, 10, 2]. That array is also the return value of the call.
  • Error messages from stored code no longer carry a class name. A value that stored code creates belongs to the sandbox realm, so a host-side instanceof check is false for it. An extension validation message read HookError: Title is required, and cron history and test results gained the same prefix.
  • Relational pickers showed no Add Existing or Create New button. The component upgrade replaced a local setting, so permission requests went to a different origin and the browser blocked them. The failure was silent, and it read as a permissions problem.
  • A failed delete on the roles and users pages showed a success message, and the detail pages still navigated away.
  • A failed relation lookup is logged again instead of looking like a collection with no relations.
  • Relation fields no longer display [object Object]. A foreign key object reached the display template of an M2M field, a relation display, and a CollectionForm fallback message, and each rendered the object as text.
  • The “all” and “none” links in the permissions row work from the keyboard.

Internal

A SonarQube pass covers 19 rules across the codebase, including unused imports, deprecated client use, nested ternaries, optional chaining, node: imports, and readonly props. Those changes do not alter behavior. Two real defects came out of the same pass, and both are listed above. The repository also gains a Vitest unit-test setup and a coverage pipeline that reports to SonarQube.