Baselyra Docs

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

Trust boundaries

Two databases

Project database (baselyra)Control database (baselyra_control)
Holdsauth, storage, your public tables, this project's configStudio 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 editorBaselyra's own code, through a separate pool
The service key can read itYes, entirelyNo

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 sentPostgres roleRLS
nothing, or the anon keyanonenforced
a user's access tokenauthenticatedenforced, and auth.uid() is that user
the service keyservice_rolebypassed (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.
  • exp and nbf are enforced, and an unknown role claim is refused.
  • Nothing is taken from an unverified payload except the ref claim, and that is used for exactly one thing: choosing which project's secret verifyJwt is handed. A forged ref therefore selects a secret the token was not signed with, and verification fails before a row is read.

Keys, tokens and hashes

CredentialStored or signed howLifetimeRevoked by
anon keyJWT, HS256, JWT_SECRET10 yearsrotating JWT_SECRET
service keyJWT, HS256, JWT_SECRET10 yearsrotating JWT_SECRET
access tokenJWT, HS256, JWT_SECRETJWT_ACCESS_TTL (1h)expiry only
refresh token48 random bytes, stored as-is in auth.sessionsJWT_REFRESH_TTL (30d)logout, rotation, or revoking the session row
Studio tokenJWT with typ: "platform"1 hourexpiry only
email / recovery / OTP tokenSHA-256 digest in auth.one_time_tokens, scoped to the address or numberper flowsingle use
Studio and user passwordsscrypt$N$r$p$salt$hash, N=16384 r=8 p=1
imported passwordsbcrypt$… verbatim, verified through pgcrypto and re-hashed to scrypt on first sign-in
signed storage URLHMAC-SHA256 over bucket, key and expiry, JWT_SECRETas requested, 1s–7dexpiry only
webhook deliveryHMAC-SHA256 over <timestamp>.<body>, per-webhook secretrotating 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.

StatementAs baselyra_sqlWhy
copy t from program 'sh -c …'refusedneeds pg_execute_server_program or superuser
select pg_read_file('/etc/shadow')refusedneeds pg_read_server_files or superuser
select lo_export(…, '/some/path')refusedneeds pg_write_server_files or superuser
alter system set …refusedsuperuser, or a GRANT … ON PARAMETER that is never issued
create extension plpython3u, file_fdwrefuseduntrusted extensions are superuser-only
create extension pgcryptoallowedtrusted extension, plus CREATE on the database
drop table public.postsallowedownership rights, granted deliberately
alter table auth.users …allowedsame
select encrypted_password from auth.usersallowedBYPASSRLS, and the console is for this
select * from control.platform_usersfailsdifferent 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

LayerLimit
Globally300 requests per minute per IP across the whole API, answering 429
POST /admin/v1/login5 per minute — the one route that turns a password into a token holding service_role, callable with no credential
/auth/v1/signup10 per minute
/auth/v1/token, /verify30 per minute
The email and OTP flows5 per minute
Sign-in attemptsauth.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 sendsA 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_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 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, apikey and cookie headers are redacted at every level.
  • Emails are printed to the log instead of sent when SMTP_HOST is empty — which means recovery links in your log file.

Hardening checklist

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

    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.

  2. JWT_SECRET is at least 32 random bytes and was never committed anywhere

    scripts/setup.sh generates one.

  3. .env is chmod 600 and outside version control

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

  5. The app port is bound to 127.0.0.1, TLS terminates in your proxy, and BASELYRA_PUBLIC_URL is https://

  6. Postgres publishes no port

  7. Every table in public has RLS enabled and at least one policy

  8. BASELYRA_ADMIN_EMAIL and BASELYRA_ADMIN_PASSWORD are cleared from .env

    After the first sign-in. Left in place they are a second known credential.

  9. GET /admin/v1/team lists only people who still work here, and viewers are viewers

    Remember that viewer holds keys.read, so every Studio account can read the service key.

  10. The service key appears in no client bundle

    Grep your frontend for it.

  11. Backups run, land off the machine, and have been restored once

    Including the control database, or nobody can sign in to the restored instance.

  12. SMTP is configured

    So recovery emails leave the machine instead of landing in the log.

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

GapDetail
A superuser DATABASE_URLLeaves the SQL editor one RESET ROLE from the host. This is the default in the shipped compose file.
Studio tokens cannot be revokedThey 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 issuedNot 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 localStorageNo 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 keyTo 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_roleAn RLS-filtered admin grid would quietly lie about what a table contains, so it does not filter.
No two-factor authenticationOn Studio accounts, and no password policy beyond a minimum length.
Nothing is encrypted at restNot the database, not the storage volume, not .env. Use full-disk encryption on the host if you need it.
Public buckets are publicAny 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 trustWebhook 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 protectionThe 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 lossThe 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.

Edit this page Report a problem

Esc
navigate open Esc close