mybasev1.0.21

Security

The security advisor, the audit log, alerts, and what mybase does to keep the dashboard and your projects apart.

Three tools on the dashboard's Security page (also in the CLI): the advisor finds mistakes, the audit log says who did what, and alerts tell you when something suspicious happens.

Security advisor

Read-only checks, run each time you open the page or call it. Nothing is stored or changed.

mybase doctor             # this instance + every project (admin key); exits 1 on critical findings
mybase doctor shop        # one project (projects:read)

GET /advisor (admin) and GET /projects/<p>/advisor (projects:read) return { checkedAt, findings, checks }; each finding has id, severity (critical, warn, info), project, title, detail, and fix. MCP: security_advisor.

Instance checks:

CheckWhat it means
admin-no-2faThe admin signs in with a password alone. Turn on 2FA in Settings.
secrets-key-unsetMYBASE_SECRETS_KEY isn't set (critical when a project uses secrets).
secrets-key-old-setMYBASE_SECRETS_KEY_OLD is still set after the re-wrap.
admin-password-in-envMYBASE_ADMIN_PASSWORD is still in the environment.
weak-service-secretPOSTGRES_PASSWORD, VALKEY_PASSWORD, or GARAGE_ADMIN_TOKEN is short or a placeholder. Values are never shown.
tunnel-no-trust-proxyRequests come through Cloudflare but MYBASE_TRUST_PROXY isn't set, so every visitor looks like one IP.
public-url-httpMYBASE_PUBLIC_URL is plain http on a public address.
trust-proxy-invalidMYBASE_TRUST_PROXY is neither cloudflare nor a list of IPs/CIDRs, so no proxy is trusted.
outbound-privateCron jobs and realtime webhooks may call private addresses (MYBASE_OUTBOUND isn't public on an https instance), or any address (any).
admin-token-stale, token-unusedAPI keys unused for 90+ days.
control-schema-publicProject roles could read mybase's own tables.
db-public-offMYBASE_DB_PUBLIC is on but the host or DNS token is missing.
db-public-certPublic Postgres's certificate couldn't be issued, or expires within 14 days.
db-public-dnsThe database's DNS record couldn't be updated.

Project checks: other projects can connect to the database (database-public-connect), the project role has extra privileges (role-privileged), anyone can create objects in public (public-schema-create), tables granted to PUBLIC (grants-to-public), row-level security on but not forced (rls-not-forced: apps connect as the table owner, which skips RLS unless forced), tables without RLS once the users service is on (tables-no-rls), SECURITY DEFINER functions without a fixed search_path, files under public/, analytics open to any origin, and Better Auth's tables missing (users-not-migrated).

Audit log

Every request that changes something is recorded: sign-ins (and failed ones), 2FA changes, API keys, projects, services, secrets changes and reads (names only), SQL editor runs (never the SQL), row edits, file uploads and deletes, syncs, rejected API keys, rejected database connections, and your app's sign-ins from the users service. High-volume app calls (cache commands, rate limit checks, queue sends and receives, analytics events) are not.

mybase audit                                  # newest 50
mybase audit --action auth --since 24h        # a group (auth, mfa, token, project, service, secrets, database, storage, users, …)
mybase audit --project shop --outcome denied
mybase audit --follow                         # keep printing new entries
mybase audit retention 30                     # keep 30 days (7–365, default 90; admin key)

GET /audit?project=&action=&actor=&outcome=&since=&before=&limit= needs audit:read. Entries are newest first; pass next as before for older ones. A key limited to some projects sees only their entries. MCP: audit_events.

Each entry has who (session = the admin's browser, token = an API key by name, anonymous, or system), the IP, the action, its outcome (ok, denied, failed), the project, a target (a key id, an env name, a table, a file), and a few counts or flags. Secret values, passwords, SQL text, row values, and request bodies are never recorded.

Entries are buffered and written every 10 seconds (fewer writes on an SD card) and on shutdown. A crash can lose the last few seconds. Old entries are deleted hourly.

Alerts

Built-in rules watch the audit log and send a message when one fires. Each rule sends at most once per 15 minutes.

RuleFires when
login.failed_burst10+ failed admin sign-ins within 10 minutes
login.new_ipthe admin signs in from an IP not seen in 30 days
mfa.disabled, mfa.recovery_used2FA turned off, or a recovery code used
auth.password_changedthe admin password changed
token.admin_createda new admin API key
secrets.new_readeran API key reads a secrets env it hadn't read in 30 days
key.rejected_burst, dbproxy.rejected_burstmany wrong API keys or rejected database connections
advisor.criticalthe daily advisor run finds a new critical problem

Send them to an ntfy topic (push notifications to your phone) or a webhook:

mybase alerts add ntfy https://ntfy.sh/<long-random-topic> [--token tk_…]
mybase alerts add webhook https://example.com/hooks/mybase
mybase alerts test <channel-id>
mybase alerts rule login.new_ip off

Webhooks get a JSON POST, { rule, title, detail, at, instance }, signed like cron calls. Verify it with the channel's secret:

import { verifyCronSignature } from "@mybase-dev/sdk/cron";

const body = await req.text();
await verifyCronSignature({ body, signature: req.headers.get("X-Mybase-Signature"), secret: process.env.MYBASE_ALERT_SECRET! });

Failed sends are retried twice (10 s, 60 s). Sends are audit entries too (alert.sent, alert.failed). ntfy access tokens are encrypted with MYBASE_SECRETS_KEY when it's set.

What mybase does for you

  • Public files can't act as the dashboard. /storage/<p>/public/* shares the dashboard's origin, so every stored file is served with X-Content-Type-Options: nosniff and a sandbox CSP, and HTML, SVG, XML, and JavaScript download instead of rendering.
  • Security headers: the dashboard has a strict Content Security Policy, refuses to be framed, and sends HSTS over https. Signed-in API answers aren't cached.
  • Default deny: every route needs a signed-in admin or an API key with the right permission, unless it's deliberately public. Cross-site writes are refused (CSRF).
  • Throttling: sign-ins and wrong API keys back off per IP; 2FA is available for the admin. Proxy headers (CF-Connecting-IP, X-Forwarded-For) only count from the proxies MYBASE_TRUST_PROXY names, so clients can't spoof their way past the throttles.
  • Project credentials stay inside the project. The table editor, the users service, and vector search work in a project's database through mybase-as-<project>, an unprivileged login that can switch to the project's role but has no rights of its own. A view, trigger, or function the project wrote (even one that runs RESET ROLE) never runs as superuser. mybase also refuses to use the mybase_vector or mybase_auth schemas if the project created them first.
  • Webhooks can't reach inside. Cron jobs, one-off tasks, and realtime webhooks refuse loopback, link-local (cloud metadata), and the addresses of mybase's own containers; MYBASE_OUTBOUND=public also refuses private ranges.
  • Hosting stays in its lane. Image pushes can only mount layers from the same project's repositories, and site env names that would steer the hosting worker's docker client (DOCKER_*, LD_*, PATH, HOME) are refused.

For what's left to you (installation, keys, and each service), see Security best practices.

Agent notes

  • Run security_advisor (MCP) or mybase doctor <project> --json after changing grants, RLS, or functions; fix critical and warn findings first.
  • The audit log never holds secret values or SQL; don't expect to find them there.
  • Alert channels and audit retention need an admin key; reading the audit log needs audit:read.

On this page