Security
Read this alongside Row level security: that page is the authorisation mechanism, and this one is the context around it. The last section lists what Baselyra does not defend, because a security page that only lists strengths is marketing.
The short version
- 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 below.
Trust boundaries
Two databases
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 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.
Three request roles
Every request that touches your data runs through asRole(), which opens a
transaction, switches the Postgres role, and publishes the caller's verified claims
as request.jwt.claims. 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, and auth.uid() is that user |
| the service key | service_role | bypassed (BYPASSRLS) |
An application-level admin is a claim, not a column:
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'. There is no
is_admin flag and no auth.is_admin() function — an earlier build had both,
and they conflated your customers with your operators.
How the JWT is verified
HS256 on node:crypto, about forty lines. What matters is done explicitly:
- The algorithm is pinned.
alg: "none"and any asymmetric algorithm are rejected outright, which is what stops the classic algorithm-confusion forgery. - The signature is compared in constant time, after a length check.
expandnbfare enforced, and an unknownroleclaim is refused.- Nothing is taken from an unverified payload except the
refclaim, and that is used for exactly one thing: choosing which project's secretverifyJwtis handed. A forgedreftherefore selects a secret the token was not signed with, and verification fails before a row is read.
Keys, tokens and hashes
| Credential | Stored or signed 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 with typ: "platform" | 1 hour | expiry only |
| email / recovery / OTP token | SHA-256 digest in auth.one_time_tokens, scoped to the address or number | per flow | 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 bucket, key and expiry, JWT_SECRET | as requested, 1s–7d | expiry only |
| webhook delivery | HMAC-SHA256 over <timestamp>.<body>, per-webhook secret | — | rotating the secret |
Passwords are hashed, never encrypted-and-decryptable, 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.
Where the service key must never appear
- 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.
RLS is the authorisation mechanism
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.
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, 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.
The SQL editor
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 and
pg_read_file() reads any file it can open. 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 that is never issued |
create extension plpython3u, file_fdw | refused | untrusted extensions are superuser-only |
create extension pgcrypto | allowed | trusted extension, plus 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.
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.
Rate limiting and lockout
| Layer | Limit |
|---|---|
| Globally | 300 requests per minute per IP across the whole API, answering 429 |
POST /admin/v1/login | 5 per minute — the one route that turns a password into a token holding service_role, callable with no credential |
/auth/v1/signup | 10 per minute |
/auth/v1/token, /verify | 30 per minute |
| The email and OTP flows | 5 per minute |
| Sign-in attempts | auth.attempts records every failure; three counters are read from one scan — email+IP (AUTH_MAX_ATTEMPTS, 8), the email alone (×4) and the IP alone (×10) |
| SMS sends | A 60-second cooldown per number and a hard cap of 5 per number per hour, because every message is a charge on the operator's account |
The email+IP 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.
Enumeration: /signup, /recover, /magiclink, /otp and
/resend return the same status, the same body and comparable timing whether or
not the address exists — a 350 ms response floor absorbs the difference a database
round trip would otherwise reveal.
What is logged, and what is redacted
control.audit_logrecords every Studio action: actor, action, target, IP, timestamp and ametaobject. 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 progress stream and in error messages, by key name and by pattern, because a driver error quoting the connection string it failed on is the usual way one escapes. - 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. Request bodies are not logged;
authorization,apikeyandcookieheaders are redacted at every level. - Emails are printed to the log instead of sent when
SMTP_HOSTis empty — which means recovery links in your log file.
Hardening checklist
DATABASE_URLdoes not authenticate as a Postgres superuserThis is the one change that turns the SQL editor's role switch from a sensible default into an actual boundary.
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, then
pg_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 anywherescripts/setup.shgenerates one..envischmod 600and outside version controlCORS_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.1, TLS terminates in your proxy, andBASELYRA_PUBLIC_URLishttps://Postgres publishes no port
Every table in
publichas RLS enabled and at least one policyBASELYRA_ADMIN_EMAILandBASELYRA_ADMIN_PASSWORDare cleared from.envAfter the first sign-in. Left in place they are a second known credential.
GET /admin/v1/teamlists only people who still work here, and viewers are viewersRemember that
viewerholdskeys.read, so every Studio account can read the service key.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 scheduleThe Postgres and Node base images are where most CVEs will reach you.
What is not protected
Stated plainly.
| Gap | Detail |
|---|---|
A superuser DATABASE_URL | Leaves the SQL editor one RESET ROLE from the host. 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/keys returns the service key | To any Studio session, including a viewer. That is what the Connect panel is for, and it means a Studio account 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 | 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 total 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. |
| No WAF, no bot detection, 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_SECRET and every uploaded file are on it. Nothing here is designed to survive root on the VPS. |
Reporting something
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.