Skip to content

Security: DeveloperSarim/baselyra

Security

docs/security.md

Security

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.

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_URL login role probably still can — see The SQL editor.

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 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.

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 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 access

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).

Keys, tokens and hashes

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.

Where the service key must never appear

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.

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.

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.

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, 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.

Rate limiting and lockout

  • 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.attempts records 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, /otp and /resend return 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.

What is logged, and what is redacted

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_runs summary, 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_HOST is 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.

Hardening checklist

  • DATABASE_URL does 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, then pg_restore --no-owner each dump into an empty database connected as baselyra_app, which makes it the owner of everything it creates. Point DATABASE_URL at it and restart; scripts/migrate.js re-applies the grants for the new owner. Keep the superuser credentials for the day you need them, out of .env.
  • JWT_SECRET is at least 32 random bytes and was never committed anywhere. scripts/setup.sh generates one.
  • .env is chmod 600 and outside version control.
  • CORS_ORIGINS names 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 and TLS terminates in your proxy; BASELYRA_PUBLIC_URL is https://.
  • Postgres publishes no port.
  • Every table in public has RLS enabled and at least one policy.
  • BASELYRA_ADMIN_EMAIL and BASELYRA_ADMIN_PASSWORD are cleared from .env after the first sign-in.
  • GET /admin/v1/team lists 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 --build on a schedule: the Postgres and Node base images are where most CVEs will reach you.

What is not protected

Stated plainly, because a security page that only lists strengths is marketing.

  • A superuser DATABASE_URL leaves the SQL editor one RESET ROLE from 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/keys returns 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_SECRET and 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.

There aren't any published security advisories