This document covers three things:
- Reporting a vulnerability β how to disclose a hole responsibly.
- Deploying Agora securely β the operator hardening checklist (TLS, database encryption, secrets, network posture).
- Security model and known limitations β what the server enforces by design, and the honest list of what to harden next.
Agora is pre-1.0 software. The model below is sound, but the surface is still evolving β read the known-limitations section before running it on untrusted, high-stakes traffic.
Please do not open a public GitHub issue for security problems.
- Preferred: GitHub Private Vulnerability Reporting β the Security β Report a vulnerability button on this repository (creates a private advisory only maintainers can see).
- Email fallback: security@recoverysky.org (or
jenova@recoverysky.org). PGP available on request.
Please include: affected version/commit, a description, reproduction steps or a PoC, and impact. If you can, suggest a fix.
What to expect: acknowledgement within 3 business days, an initial assessment within 7 days, and coordinated disclosure once a fix ships. We'll credit you in the release notes unless you'd rather stay anonymous. We don't run a paid bounty (yet), but we're deeply grateful β this is a community project and you're helping keep real people safe. π
Being pre-1.0, only the latest minor receives security fixes. Run the most recent tagged release.
| Version | Supported |
|---|---|
latest 0.x minor |
β |
| older | β β upgrade |
Agora is self-hosted, so a meaningful share of its security is your deployment. The application is written to be the trust boundary (see Security model), but it deliberately delegates transport security, TLS, and network isolation to your infrastructure. This checklist is the contract.
Bundled shortcut:
docker compose --profile supabase up(or--profile selfhost) brings up the API with a Caddy front door that does all of this automatically β auto-HTTPS (Let's Encrypt, auto-renewed),http β httpsredirect, HSTS + security headers, WebSocket upgrade, body-size cap, and an authoritativeX-Forwarded-Forβ and it also serves the admin SPA + routes every service (one hop). SetSERVER_NAME+RATE_LIMIT_TRUSTED_HOPS=1. Seedeploy/proxy/README.md. The rest of this checklist is for bringing your own proxy/CDN instead.
Put a reverse proxy (Caddy, nginx, Traefik) or a CDN (Cloudflare) in front and terminate HTTPS there. Agora speaks HTTP behind it; the public hop must be HTTPS.
- Redirect
http β https. - Send HSTS:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload. - The API does not set security headers itself β your proxy must add at minimum:
X-Content-Type-Options: nosniff, a sensibleContent-Security-Policy, andReferrer-Policy: strict-origin-when-cross-origin. - socket.io (chat realtime) needs WebSocket upgrade proxied on the same origin.
Rate limiting derives the client IP from X-Forwarded-For, read RATE_LIMIT_TRUSTED_HOPS hops from
the right (the entries trusted proxies appended), so a client-supplied left-most value can't poison
it. Your edge must set X-Forwarded-For to the real peer and not trust an inbound one β the bundled
Caddy front door does this by default (set RATE_LIMIT_TRUSTED_HOPS=1 β Caddy is the only proxy; use 2
if you chain a CDN/LB in front of it). Only expose the API through the proxy β never bind it to a public
interface directly.
- Managed Supabase Postgres (the default target) is encrypted at rest (AES-256) by the platform, with automated backups. Nothing to do beyond keeping your project access controls tight.
- In transit: ensure
DATABASE_URLenforces TLS β append?sslmode=require(or stricter,verify-full, with the CA) so the appβPostgres hop is encrypted. Verify this is present; the app does not force it for you. - Self-hosted Postgres: enable
ssl = onwith a real certificate, turn on transparent data-encryption / encrypted volumes (LUKS/EBS-encryption), and restrictpg_hba.confto the app's network. Use the transaction pooler connection (port 6543,prepare:false) the app expects.
ACCESS_TOKEN_SECRETsigns every access JWT (HS256). Use β₯ 32 bytes of randomness:openssl rand -base64 48. Rotating it invalidates all live access tokens (refresh still works) β a useful emergency lever.CRON_SECRET,MODERATION_SERVICE_SECRET, projectwebhook_secretβ random, high-entropy, unique per deployment. These gate internal/cron routes and sign webhooks (all compared in constant time).SUPABASE_SERVICE_ROLE_KEYis god-mode over your Supabase project. Agora confines it to Auth + Storage only (lazy client) β keep it server-side only, never in any client bundle or the admin app. The admin/SDK use the anon/publishable key.- Inject secrets via your platform's secret store or env β never commit
.env, never bake secrets into the Docker image. The root.envis git-ignored; keep it that way.
CORS_ORIGIN defaults to *. For production set it to your exact app origin(s)
(https://app.example.com) β both the REST API and socket.io read it. A wildcard with credentialed
requests is a cross-origin risk.
Edge rate limiting is off unless configured. Set RATE_LIMIT_MAX (per-IP per window) and the
stricter RATE_LIMIT_AUTH_MAX for /auth/*, with RATE_LIMIT_WINDOW_SECONDS (default 60). Note
it is in-memory per process by default β see known limitations.
Set REDIS_URL to hold the cap in a shared Redis store across replicas (fail-open to in-memory if
Redis is down); either way, pair it with proxy/WAF/CDN rate limiting for real quota enforcement.
The app does not currently enforce a global body-size limit (see roadmap). The bundled Caddy edge caps it
(MAX_BODY_SIZE, default 25MB); with your own proxy set client_max_body_size (nginx) / equivalent to
bound uploads and JSON bodies.
OPERATOR_USER_IDS/OPERATOR_EMAILSgrant a project-wide god-view. Keep the allowlist minimal; prefer the steward role (least-privilege, DB-granted) for day-to-day moderation/conflict work.- The Supabase Storage
agorabucket is public β an accepted design choice (most media is public by nature: post images, avatars, public-space content). Paths are randomprojectId/.../fileIdv4 UUIDs (~122 bits each), so objects can't be enumerated/guessed β the only exposure is a leaked URL (a forwarded link, a referrer header, a log). So: don't upload secrets, and be aware that a leaked URL to a private attachment (DM / private-space) is world-readable. If that matters for your deployment, put private uploads behind your own signed-URL/download gate. - The self-hosted storage backend (
STORAGE_PROVIDER=s3β MinIO/S3,docs/SELF-HOSTING.md) carries the identical posture: the api creates the bucket with an anonymous public-read policy and the same unguessable-UUID keys β same accepted trade-off, same "don't upload secrets" caveat. Serve it through the Caddy front door/mediamount (never expose MinIO:9000publicly), and treat the MinIO root credentials +POSTGRES_PASSWORDas real secrets (strong values,.envout of VCS). - Verify Supabase automated backups (or your own
pg_dumpschedule) and test a restore.
Track releases (security fixes land in the latest minor), and keep Supabase, the proxy, and the base image current.
The selfhost profile runs Supabase Auth (GoTrue) as its own container. Its posture, and the knobs
that keep it that way:
/auth/v1/*is public on purpose β OAuth providers redirect to/auth/v1/callbackand confirmation emails link to/auth/v1/verify. That is the same exposure as cloud Supabase. The admin surface under/auth/v1/admin/*is gated by GoTrue itself: it accepts only a JWT whoseroleisservice_role, verified againstGOTRUE_JWT_SECRET.SUPABASE_SERVICE_ROLE_KEYis a root credential for the identity store (it can mint, read and delete any user). It belongs on the API service only, from a secrets store β never in a client, a/config.js, or a log. Theanonkey is deliberately low-privilege and is the one supabase-js uses for password sign-in.- The
:9998shim is internal-only. The proxy exposes a second, unauthenticated path-stripping listener so the API can reach GoTrue; it must never be published to the host or a public network. In dev compose it is published (the API runs on the host there) β a dev-only concession. - Redirect targets are allowlisted twice.
/oauth/callbackhands the browser a live Agora session in the URL fragment, soredirectAfterAuthis validated on/oauth/authorizeand re-validated on the callback againstOAUTH_REDIRECT_ALLOWED_ORIGINS(falling back toPUBLIC_BASE_URL; failing closed with503when neither is set). GoTrue's ownGOTRUE_URI_ALLOW_LISTguards its hop. Keep both lists to exact origins you control. - Provider secrets rotate. Apple's client secret is a β€180-day JWT
(
gen-apple-client-secret.mjs); treat its expiry as an operational deadline. Google/GitHub secrets are long-lived β revoke and regenerate from the provider console if they leak. - GoTrue's database role is least-privilege by design β
supabase_auth_adminowns only theauthschema. On a non-supabase/postgresdatabase useapps/api/scripts/bootstrap-gotrue-role.sqlrather than reusing the application's DB role, which would hand GoTrue read/write on every table. Seedocs/SELF-HOSTING.md.
How Agora is designed to be secure β useful context for both operators and reviewers.
- The server is the trust boundary. The app connects to Postgres with a role that bypasses RLS; all authorization (ownership, space roles, operator/steward checks, moderation visibility) is enforced in the request handlers, not in the database. Treat the API as the only thing standing between a client and the data.
- Private by default (auth wall). Every
/v7/:projectId/*request requires an authenticated account. The gate isauthWall(packages/core/src/middleware/auth.ts), mounted group-wide; itsAUTH_WALL_ALLOWLISTconstant is the API's entire anonymous surface (the pre-sign-in flows:/auth/*, OAuth authorize/callback,/projects/lean, the VAPID public key, and the dev JWT-signing stub). New routes are authed by default β fail closed. Adding an allowlist entry is a security decision requiring spec rationale; a unit test pins the list's exact contents. The RLS0008anon public-read policies were revoked (0064) so the DB layer states the same posture. Uploaded media remains fetchable by unguessable URL (see the storage section) β the one anonymous-readable artifact class, queued for a signed-URL follow-up. /v7/:projectId/public/*β the second deliberate hole (internet-public entities). Full guide:docs/PUBLIC-API.md. GET-only; each route independently re-derivesentity.public AND space-is-publiclive (the gate itself is never cached) and 404s (never 403s) the moment any of that goes false β soft-delete, moderation removal, or the space going members-only un-exposes a post even while itspublicflag is stilltrue. Takedown is instant at the origin and for any reader who reloads, but bounded β not instant β at shared caches. Success responses carryCache-Control: public, max-age=0, s-maxage=300, must-revalidateplus anETag(lib/public-cache.ts):max-age=0forces every browser to revalidate, so a reload always reflects a takedown, while a CDN/proxy may keep serving a stored copy for up to 300s after one. That window is the deliberate, ratified cost of making embeds cacheable; deployments needing a hard-instant takedown should front the surface with a cache they can purge, or drops-maxage. Error responses are never cacheable βno-storeis set on every error envelope (app.ts), which matters most for the gate's own 404: a cached one would keep a freshly-published post invisible at the edge for the whole window. Responses setAccess-Control-Allow-Origin: *with no credentials (both a route-local post-next()override inroutes/public.tsfor normal responses, and, since hono's app-widecors()short-circuitsOPTIONSbefore routing, a matching case in the app-levelcors()origin callback for the preflight itself β seeapp.ts) so third-party origins can embed the content; the rest of the API keeps the configuredCORS_ORIGIN.?include=userredacts PII before it reaches the anonymous internet:birthdateand the free-form profilemetadatajsonb are always nulled/emptied on this surface (username/name/avatar/bio still ride along). The shared/v7/*rate limiter (lib/rate-limit.ts) covers this prefix too, but only whenRATE_LIMIT_MAXis configured β unset, anonymous internet-public reads are unlimited like every other/v7/*route. Spec:docs/superpowers/specs/2026-07-18-internet-public-entities-design.md.- Row-Level Security is defense-in-depth. Every table has RLS enabled with a deny-all backstop.
The
0008anon public-read policies were revoked (migration0064, alongside the auth wall) andanon'sSELECTgrants pulled with them βanonnow has no read access at all. The only remaining read policies are authenticated self-access reads (a signed-in user sees only their own private rows, viaSECURITY DEFINERhelpers in a non-exposedprivateschema). There are no client write policies β all writes are server-only β andprofilesis intentionally not exposed (RLS can't mask theemail/secure_metadatacolumns). So even if an anon/authed key ever reached the DB directly, the blast radius is bounded (anon gets nothing; an authed key gets only its own rows). - Multi-tenant isolation by
project_id.:projectIdis validated (UUID + existence) in middleware and every query is scoped to it; tenants can't read across each other. - DB resolution fails closed. The per-project DB resolver seam (
@agora/core/db) propagates resolver errors β there is no "resolver failed β shared database" fallback, so a misconfigured multi-DB deployment can never silently serve one project's request from another's database. Single-DATABASE_URLdeployments are unaffected (no resolver is ever registered; every request uses the one shared handle). - Authentication. Identity is backed by Supabase Auth (passwords never touch Agora code β they go
straight to Supabase; never logged or stored). Agora mints its own HS256 access JWTs (short-lived,
~30 min) plus rotating refresh tokens with reuse-detection (a replayed token revokes the whole
family), a 30-second grace window for racing tabs, SHA-256-hashed storage, and a cron that purges
expired tokens. External identity (
verify-external-user) uses RS256 with per-project public keys and pinned audience + issuer. Native-auth email links fail closed: the confirm/reset/resend paths requireAUTH_EMAIL_LINK_ALLOWED_ORIGINS(there's no way to validate a client-suppliedemailRedirectTowithout it) β unset, they return503 auth/email-not-configuredrather than email a link built from an unvalidated origin; a suppliedemailRedirectTois checked against that allowlist (open-redirect guard, else400 auth/email-redirect-not-allowed). Supabase-backed auth brokers its own emails and is unaffected. - Authorization tiers. A hierarchy
operator β owner β admin β steward β member. The deployment platform-operator (env allowlist, cross-tenant) plus DB-granted per-projectowner/admin/steward(project_roles) are stamped into the JWT (operator/powner/padmin/stewardclaims) and read back per request β no extra DB hit. Within-project powers (moderation, reports, suspensions, project config, private-space access) accept owner/admin; deployment powers (running config, DB size, server resources) stay operator-only. Role grants take effect on the user's next token refresh (see the revocation-latency limitation below). - Settings-read-only principals (
OPERATOR_RO_EMAILS): a shared demo/operator login can hold the full operator view yet is server-blocked (assertSettingsWritable, after the project-admin gate) from persisting any of the five settings-save endpoints. Per-identity and server-enforced β independent of, and stricter than, the client-sideVITE_SETTINGS_READ_ONLYdisplay flag. Additive; no existing gate is relaxed. - Internal endpoints & webhooks (cron, moderation apply, webhook signatures) are gated by secrets
compared in constant time (
crypto.timingSafeEqual); webhooks are HMAC-SHA256 signed with a timestamp, in both directions. - Injection-resistant data access. All SQL goes through Drizzle's parameterized
sqltemplate β including the raw RPC/search/rollup queries; no user input is concatenated into SQL. Email-enumeration is avoided on auth flows (uniform 200s), and the link-preview fetch is SSRF-guarded (scheme allowlist, private-IP/loopback/metadata blocking, timeout, response-size cap). - Secrets don't leak. The operator config endpoint reports secrets as booleans and strips credentials
from
DATABASE_URL; request logging records method/path/status/duration only β no bodies, tokens, or headers.
Honest disclosure of where the implementation is thinner than the model, roughly by priority. These are the areas we're actively looking into; contributions welcome.
| Area | Status / risk | Direction |
|---|---|---|
| Secure Chat crypto is unaudited | ts-mls (pinned 1.6.2). Neither ts-mls nor Agora's integration around it has had an independent security audit. ts-mls's own README states it "has not undergone a formal security audit"; it is single-maintainer. Agora's layer on top (session management, persistence, key-package handling, history restore) is likewise unreviewed. The blind-relay architecture is sound by design β the server stores only ciphertext and holds no keys β but design intent is not the same as verified implementation, and a client-side protocol flaw would not be visible from the server side at all. Do not rely on Secure Chat where compromise would put someone at risk. |
Independent cryptographic review is explicitly welcome and is a reason this code is public. All MLS sits behind the small SecureChatCrypto interface, so the concrete core is a deferred, reversible choice β OpenMLS (Rust/WASM, independently audited by SRLabs, March 2026) is the intended upgrade path if/when the binding work is justified; the server contract does not change either way. |
| Link-preview SSRF (redirects + encodings) | β
Fixed. /utils/get-metadata now validates the host on the initial URL and every redirect hop (manual redirect following via lib/ssrf.ts), resolves the host and rejects any private resolved IP, and covers IPv6 (incl. IPv4-mapped) + numeric-IP encodings (decimal/octal/hex). |
Residual: a narrow DNS-rebinding TOCTOU between our resolve and fetch's own resolution. Close it later by pinning the connection to the validated IP (a custom dispatcher/lookup). |
| User-suspension enforcement | β
Fixed. requireAuth now rejects an actively-suspended user (403 auth/suspended) on every authed request (and every secure-chat socket handshake), and suspending revokes the user's refresh families so the session can't be renewed. Operators bypass (they lift). Operator suspend/lift endpoints added (/users/:id/suspend). Now backed by a fail-closed Redis index β see the row below. |
Follow-up: an admin-app UI to view/manage suspensions. |
| Suspension index β fail-closed | β
Hardened. When REDIS_URL is set, the per-request/per-handshake suspension check (hasActiveSuspension) reads a Redis SET suspended:profiles (O(1), no DB hit), kept correct by hydrate-on-boot (atomic rebuild) + write-through SADD/SREM on suspend/lift + a reconcile cron (POST /internal/cron/sync-suspensions, every 5 min). It fails closed: a configured-but-unreachable Redis returns 403/503 (the request is denied β there is no DB fallback for a down-but-configured Redis, which would fail open). The DB-read path is used only when Redis is not configured (single-replica API + the hermetic test suite). The standalone @agora/secure-chat service treats Redis as a hard dependency and gates readiness on a /health check that returns 503 until the index hydrates β so an empty set can't fail open before boot. |
β |
| Rate-limit durability + IP spoofing | β
Fixed. The client IP is now read RATE_LIMIT_TRUSTED_HOPS hops from the right of X-Forwarded-For (was the spoofable left-most), and an optional Redis store (REDIS_URL, least-privilege ACL) holds the cap across replicas β fail-open to in-memory. Still off unless RATE_LIMIT_MAX is set. |
Pair with proxy/WAF limits at very high scale; cf-connecting-ip support if Cloudflare-fronted. |
| Upload size / image bounds | β
Fixed. App-layer cap MAX_UPLOAD_BYTES (default 25 MiB, 413) on every upload path + a 50 MP image limit (sharp limitInputPixels + a metadata pre-check), in addition to the proxy's body cap. |
β |
| Public storage bucket | β
Accepted (documented). The agora bucket is public by design (most media is public); paths are unguessable v4-UUIDs so there's no enumeration β only leaked-URL exposure. Signing every URL would tax all media reads + break caching to protect mostly-public content (poor trade). |
Residual: a leaked URL to a private attachment is world-readable β don't upload secrets; gate private uploads yourself if needed (see deploy checklist Β§8). |
ACCESS_TOKEN_SECRET strength |
β
Fixed. Env validation now requires min(32) (was min(1)); openssl rand -base64 48 documented above. |
β |
| JWT verify algorithm pinning | β
Fixed. algorithms: ["HS256"] pinned on the access-token + socket.io verifies (and the services/scorer write-back); the external-auth verify pins ["RS256"]. |
β |
| Security headers | β
Addressed at the edge. The bundled Caddy proxy sends HSTS + X-Content-Type-Options + Referrer-Policy + X-Frame-Options (and strips Server). The API itself still sets none. |
Bring-your-own proxy must add them; a strict app-specific CSP is the remaining tuning (commented starting point in the Caddyfile). |
| Role/privilege revocation latency | ACCESS_TOKEN TTL). So a revoked admin/owner retains access for that window; for owner it also includes suspension-immunity. The cross-replica role cache adds β€30s on top (it's in-process, not the shared Redis β which today serves only rate limiting). This is the same tradeoff already accepted for the operator/steward flags, but more consequential for admin/owner. No store is consulted on the request hot path, so neither the cache nor Redis is the dominant factor β the live JWT is. |
Make revoke bite immediately: (a) revokeAllForProfile() on owner/admin revoke (kills refresh-extension; cheap, no infra, ~90% of the value), and/or (b) a per-request token-version / roles_epoch check (true immediate revocation β the one place a shared fast store like Redis would earn its keep on the auth path). Interim mitigation: shorten ACCESS_TOKEN TTL. Tracked for the managed-hosting isolation-hardening pass (sub-project G). |
| CORS default | Defaults to * if unset. |
Documented; consider failing/βwarning loudly on a wildcard in production mode. |
| RLS write policies | None by design (writes are server-only). Safe today, but means RLS offers no second line for writes if the app boundary is bypassed. | Tracked as a deliberate trade-off; revisit if direct-DB access patterns are ever introduced. |
Security is a practice, not a checkbox. If something here is wrong, unclear, or out of date, please tell us β privately for anything exploitable.