What Baselyra defends, how, and — the part most pages like this leave out — what it does not defend. Read row-level-security.md alongside it: RLS is the authorisation mechanism, and this page is the context around it.
- Postgres decides who may read a row. The server never filters for security.
- Two databases: the thing that grants access to the console does not live inside the thing the console administers.
- The service key is a permanent superuser over your data. It belongs on a server, never in a browser, a mobile app, or a repository.
- The SQL editor runs as a role that cannot reach the host. Your
DATABASE_URLlogin role probably still can — see The SQL editor.
Project database (baselyra) |
Control database (baselyra_control) |
|
|---|---|---|
| Holds | auth, storage, your public tables, this project's config |
Studio accounts, audit log, import history, request metering, the project registry |
| Reachable from | /rest/v1, /auth/v1, /storage/v1, /realtime/v1, the Studio's Database browser and SQL editor |
Baselyra's own code, through a separate pool |
| The service key can read it | Yes, entirely | No |
Postgres has no cross-database queries without FDW, so this is a boundary rather
than a convention: select * from control.platform_users in the SQL editor
fails with "relation does not exist", and it would fail identically for anyone
holding the service key. The Studio's password hashes are not protected by a
grant that could be widened by mistake — they are in a database that connection
cannot address at all.
GET /admin/v1/schema never returns the baselyra schema either. That one is
this project's own configuration and has purpose-built Studio pages (Settings,
Email, Realtime); auth and storage stay visible, grouped as system, because
writing policies against auth.users is a legitimate thing to do.
Every request that touches your data runs through asRole(), which opens a
transaction, switches the Postgres role, and publishes the caller's claims as
request.jwt.claims for policies to read. Both settings are LOCAL, so the
commit restores the pooled connection and no identity survives into the next
request.
| Caller sent | Postgres role | RLS |
|---|---|---|
| nothing, or the anon key | anon |
enforced |
| a user's access token | authenticated |
enforced, auth.uid() is that user |
| the service key | service_role |
bypassed (BYPASSRLS) |
An application-level admin is a claim, not a column: write
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' in a policy. There is no
is_admin flag on auth.users and no auth.is_admin() function — an earlier
build had both, and they conflated your customers with your operators.
Studio accounts live in control.platform_users and have nothing to do with
auth.users. A Studio password is not a project password, exactly as a Supabase
dashboard login is unrelated to a project's users.
POST /admin/v1/login is the only endpoint that mints a Studio token: a JWT
carrying typ: "platform", the account's platform role, and the capabilities
that role grants. Every other /admin/v1 route requires that token or the raw
service key. No token a project end user can obtain carries typ: "platform",
whatever else it holds, so there is no path from a signed-up user to the
console.
Platform roles are owner (may manage the team), admin (everything else), and
viewer (read-only — every mutating /admin/v1 route refuses it, including
writes in the SQL editor).
| Credential | Signed/stored how | Lifetime | Revoked by |
|---|---|---|---|
| anon key | JWT, HS256, JWT_SECRET |
10 years | rotating JWT_SECRET |
| service key | JWT, HS256, JWT_SECRET |
10 years | rotating JWT_SECRET |
| access token | JWT, HS256, JWT_SECRET |
JWT_ACCESS_TTL (1h) |
expiry only |
| refresh token | 48 random bytes, stored as-is in auth.sessions |
JWT_REFRESH_TTL (30d) |
logout, rotation, or revoking the session row |
| Studio token | JWT, typ: "platform" |
1 hour | expiry only |
| email/recovery/OTP token | SHA-256 digest in auth.one_time_tokens |
AUTH_OTP_TTL |
single use |
| Studio and user passwords | scrypt$N$r$p$salt$hash, N=16384 r=8 p=1 |
— | — |
| imported passwords | bcrypt$… verbatim, verified through pgcrypto and re-hashed to scrypt on first sign-in |
— | — |
| signed storage URL | HMAC-SHA256 over the path and expiry, JWT_SECRET |
as requested | expiry only |
| webhook delivery | HMAC-SHA256 over <timestamp>.<body>, per-webhook secret |
— | rotating the secret |
Passwords are never encrypted-and-decryptable; they are hashed, and each hash carries the parameters it was made with, so the cost can be raised later without invalidating anything. A failed sign-in burns the same scrypt work as a successful one, so timing does not reveal whether an address is registered.
JWT_SECRET is the root of almost all of this. Anyone holding it can mint a
service key, forge any access token, and sign any storage URL. It lives in
.env, in plaintext, chmod 600 — that is the honest state of secret storage
here. Rotating it invalidates every session and both project keys at once.
It bypasses RLS on every table in the project database. Treat it as the database password it effectively is.
- Not in a browser bundle, a React/Vue/Svelte env var, a mobile app, or a desktop app. Anything shipped to a user's device is public.
- Not in a repository, a CI log, a screenshot of the Studio, or a support ticket.
- Not in a
VITE_,NEXT_PUBLIC_,EXPO_PUBLIC_or equivalent variable — those are compiled into the client by design. - Not in a URL query string, where it lands in proxy and browser history logs.
The anon key is the one clients get. It is public by design and worthless without policies that let it read something; it is also project-scoped, so presenting it to another project on the same instance fails the signature check before a row is read.
If a service key leaks: rotate JWT_SECRET, restart, and re-issue the anon key
to your clients. There is no per-key revocation list.
No route handler checks ownership. There is no WHERE user_id = … added by the
server for security reasons. If a policy is wrong, the database says no; if a
policy is missing, the database says nothing at all.
A table in public with RLS off is readable and writable by anon — that
is, by anyone on the internet holding your public anon key — because 001's
grants give anon and authenticated DML on the schema and RLS is the only
thing that narrows it. This is the single most likely way to expose data with
Baselyra. The Studio's Database page shows the RLS state of every table, and
row-level-security.md has the query
that lists tables without policies.
Two things bypass policies on purpose: service_role, which has BYPASSRLS,
and the table owner, because RLS is enabled but not FORCEd — that exemption is
what lets the auth module manage sessions and tokens on the owner connection.
ALTER TABLE … FORCE ROW LEVEL SECURITY if you want the owner subject to its own
policies too.
Realtime obeys the same rule the hard way: before a change is delivered to a subscriber, the row is re-read as that subscriber's role and dropped if RLS hides it. Broadcast and presence channels never touch the database, so anything you put in them is visible to every subscriber of that channel.
POST /admin/v1/sql and the Studio's table and policy endpoints run the
operator's statement as baselyra_sql, a NOLOGIN role the migrations create,
reached by a SET LOCAL ROLE inside the transaction and undone by the commit.
Before that change they ran on the server's own connection, as the role in
DATABASE_URL — which in the shipped docker-compose.yml is POSTGRES_USER,
the initdb superuser. That made the admin console a remote shell: COPY … FROM PROGRAM runs a command as the postgres user, pg_read_file() reads any file it
can open, and neither is a bug in Postgres — they are superuser features.
| Statement | As baselyra_sql |
Why |
|---|---|---|
copy t from program 'sh -c …' |
refused | needs pg_execute_server_program or superuser |
select pg_read_file('/etc/shadow') |
refused | needs pg_read_server_files or superuser |
select lo_export(…, '/some/path') |
refused | needs pg_write_server_files or superuser |
alter system set … |
refused | superuser, or a GRANT … ON PARAMETER we never issue |
create extension plpython3u, file_fdw |
refused | untrusted extensions are superuser-only |
create extension pgcrypto |
allowed | trusted extension, CREATE on the database |
drop table public.posts |
allowed | ownership rights, granted deliberately |
alter table auth.users … |
allowed | same |
select encrypted_password from auth.users |
allowed | BYPASSRLS, and the console is for this |
select * from control.platform_users |
fails | different database |
The role deliberately keeps everything in the second half of that table. An
admin console is meant to be powerful inside its own database, and every one of
those is something the operator could do from psql anyway. What it loses is
access to the host, which was never part of the job.
There is no statement blocklist, and adding one would be a mistake. A filter
that catches DROP TABLE but not a do $$ … $$ block assembling the same string
is a Postgres parser written badly; its only real effect is the confidence to
expose the endpoint more widely. The role is enforced by Postgres against the
current user, which no amount of string manipulation in the request body can
change.
test/security.test.ts pins the table above, and the last block of
db/project/006_sql_role.sql re-checks it in the database on every boot: if
baselyra_sql is ever a superuser, or inherits one of the host-access roles, the
migration aborts and the container does not start.
What this does not fix. If DATABASE_URL authenticates as a superuser — the
default in the shipped compose file — then a Studio operator who deliberately
types RESET ROLE; before their statement is a superuser again, because the
session's authenticated user still is one. SET ROLE narrows what a statement
does by default; it is not a jail. Closing that means not being a superuser in
the first place: see the checklist below. The change is still worth having
without it — it confines every statement nobody deliberately wrote as an escape,
including anything an AI assistant, a saved query or a copy-pasted snippet puts
in the editor.
POST /ai/v1/ask is the one path left where SQL the operator did not write
reaches the database on the server's own connection. It is admin-only, wrapped in
a READ ONLY transaction with a statement timeout, and rolled back either way,
but a read-only transaction does not stop pg_read_file(). Leave DEEPSEEK_API_KEY
unset if that trade is wrong for you; every /ai/v1 route then answers 503.
- Globally: 300 requests per minute per IP across the whole API
(
@fastify/rate-limit), answering 429. POST /admin/v1/login: 5 per minute. It is the one route that turns a password into an admin token and needs no credential to call.- Sign-in attempts:
auth.attemptsrecords every failure and three counters are read from one scan — the email+IP pair (AUTH_MAX_ATTEMPTS, default 8), the email alone (×4) and the IP alone (×10). The pair is the documented key; the wider two exist because rotating either half bypasses it, and they sit far enough above the threshold that a shared office NAT does not trip them. The reply is a 429 with the seconds to wait; nothing is locked permanently, so no one can lock a competitor out of their own account. Rows outside the window are swept. - Enumeration:
/signup,/recover,/magiclink,/otpand/resendreturn the same status, the same body and comparable timing whether or not the address exists.
trustProxy is on, so req.ip is taken from X-Forwarded-For. That is correct
behind the reverse proxy the deployment assumes, and wrong if the port is
reachable directly — a client could then spoof the header and defeat every
per-IP limit above. The shipped compose file binds the port to 127.0.0.1 for
this reason. Keep it that way.
control.audit_log records every Studio action: actor, action, target, IP,
timestamp, and a meta object. It is in the control database, so the SQL editor
cannot read or edit it.
- The SQL editor writes its audit row before the statement runs and on a separate connection, so a statement that fails or rolls back is still recorded. That row contains the statement text and its bound parameters: do not type a password or an API key into the editor and expect it to be forgotten.
- Import runs redact as they go — connection strings, passwords and API keys are
replaced in the audit row, in the
control.import_runssummary, in the SSE progress stream and in error messages, by key name and by pattern. - Request metering (
control.request_stats) stores a route pattern, a method, a status class and timings. No bodies, no query strings, no identities. - Application logs are Fastify's, at
LOG_LEVEL. Request bodies are not logged; an unhandled error logs its stack. - Emails are printed to the log instead of sent when
SMTP_HOSTis empty. On a laptop that is convenient. In production it means recovery links in your log file, which is why the checklist says configure SMTP.
-
DATABASE_URLdoes not authenticate as a Postgres superuser. This is the one change that turns the SQL editor's role switch from a sensible default into an actual boundary. As a superuser:sql create role baselyra_app login password '…' createrole createdb; alter database baselyra owner to baselyra_app; alter database baselyra_control owner to baselyra_app;The existing objects still belong to the old role, and the tidiest way to hand them over is a restore: take a backup, thenpg_restore --no-ownereach dump into an empty database connected asbaselyra_app, which makes it the owner of everything it creates. PointDATABASE_URLat it and restart;scripts/migrate.jsre-applies the grants for the new owner. Keep the superuser credentials for the day you need them, out of.env. -
JWT_SECRETis at least 32 random bytes and was never committed anywhere.scripts/setup.shgenerates one. -
.envischmod 600and outside version control. -
CORS_ORIGINSnames your origins.*means any page on the internet can call the API with a user's token in the browser that holds it. - The app port is bound to
127.0.0.1and TLS terminates in your proxy;BASELYRA_PUBLIC_URLishttps://. - Postgres publishes no port.
- Every table in
publichas RLS enabled and at least one policy. -
BASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORDare cleared from.envafter the first sign-in. -
GET /admin/v1/teamlists only people who still work here, and viewers are viewers. - The service key appears in no client bundle. Grep your frontend for it.
- Backups run, land off the machine, and have been restored once — including the control database, or nobody can sign in to the restored instance.
- SMTP is configured, so recovery emails leave the machine instead of landing in the log.
-
docker compose pull && docker compose up -d --buildon a schedule: the Postgres and Node base images are where most CVEs will reach you.
Stated plainly, because a security page that only lists strengths is marketing.
- A superuser
DATABASE_URLleaves the SQL editor oneRESET ROLEfrom the host, as described above. This is the default in the shipped compose file. - Studio tokens cannot be revoked. They are stateless and last an hour. Signing out writes an audit row; deleting a team member stops the next login. A stolen token works until it expires.
- Refresh tokens are stored as issued, not hashed. Whoever reads
auth.sessions— a dump, a backup, the service key — can resume those sessions until they expire or rotate. One-time email tokens are stored as SHA-256 digests; refresh tokens are not. - The Studio keeps its token in
localStorage. No cookie means no CSRF surface, and it also means any script that runs on the Studio's origin can read it. Do not serve untrusted content from that origin. GET /admin/v1/keysreturns the service key to a Studio session. That is what the Connect panel is for, and it means a Studio account that can read that page is effectively a service-key holder.- The Studio's row grid runs as
service_role. An RLS-filtered admin grid would quietly lie about what a table contains, so it does not filter. - No two-factor authentication on Studio accounts, and no password policy beyond a minimum length.
- Nothing is encrypted at rest by Baselyra: not the database, not the storage
volume, not
.env. Use full-disk encryption on the host if you need it. - Public buckets are public. Any object in one is readable by URL with no token, forever, by anyone who learns the path.
- Storage paths are checked, quotas are not.
..and absolute paths are refused; there is no per-bucket size limit, so a bucket can fill the disk. - Outbound requests are yours to trust. Webhook targets are checked against private address ranges before delivery and refused unless you opt out deliberately; OAuth providers, SMTP and DeepSeek are whatever you configured.
- There is no WAF, no bot detection, and no DDoS protection. The rate limits above are per-IP counters in one process, not a defence against a botnet. Put a CDN or a proxy in front if that is your threat model.
- A compromised host is a total loss. The database password,
JWT_SECRETand every uploaded file are on it. Nothing here is designed to survive root on the VPS.
Found something worse than the above? Do not open a public issue with a working exploit in it; send it to whoever runs the instance you are looking at, and to the maintainers privately.