DocsSecurity and your data

Security and your data

The auth model, what DispatchSEO stores, what leaves your machine on a self-hosted install, and how to report a vulnerability.

On this page

The same codebase runs in two modes, and which one you're on changes what "secure" means. Self-hosted (the default) is a single-owner app: no user model, no signup, one person owns every project in the deployment. Cloud (dispatchseo.com) is genuinely multi-tenant: many customers' accounts and projects share one database, so tenant isolation is a real boundary there in a way it simply isn't on your own install. Everything below says which mode it applies to.

The auth model

Entry pointSelf-hostedCloud (dispatchseo.com)
DashboardOne shared passwordSupabase Auth (email/password or Google)
CronsAuthorization: Bearer CRON_SECRETSame
MCP serverPer-project bearer tokenSame

Dashboard, self-hosted. One password gates every page - DASHBOARD_PASSWORD if you set it as an env var, or the password you chose in the setup wizard (stored as a scrypt hash, never in plain text). The session cookie (dash_auth) is an HMAC of a fixed message keyed by that password or hash, so changing the password invalidates every existing session immediately, with no sessions table to clean up. There's no traditional middleware.ts doing the enforcement (Next 16 renamed the file to proxy.ts, and DispatchSEO's version only checks whether the cookie is present, as a fast redirect to /login); the actual HMAC comparison happens inside each protected page itself, so a forged or stale cookie value doesn't get past that page's own check even if it slips past the redirect. Login is rate-limited: 5 failed attempts from one IP locks that IP out for 15 minutes, tracked in Postgres so it holds even across restarts.

Dashboard, cloud. A Supabase Auth session, checked through one central gate, replaces the shared password entirely - there is no password to share on the hosted version. Every project row carries an owner_user_id, and any action that takes a project id or a row id asserts ownership before touching anything, so one customer's session can never reach another customer's rows by guessing or reusing an id. Cross-tenant reads and writes are treated as reportable vulnerabilities here, including subtler ones than plain data exposure - one tenant clearing another's alerts, or influencing another's scheduled work, both count.

Crons and MCP, both modes. CRON_SECRET is one shared, instance-wide bearer token that authenticates the scheduled jobs. The MCP server uses a different model entirely: a 192-bit bearer token per project, and the token itself is the tenant - it can only ever touch its own project's rows, and the server is built never to reveal that any other project exists, even to a caller poking at it with a valid token for a different project.

The dashboard password is the whole gate

On a self-hosted install, that one password is the only thing standing between the internet and everything DispatchSEO knows - keywords, rankings, Search Console data, and every token described below. Make it long and random, and treat it like an admin password to a real system, because that's exactly what it is.

Database access

The application always talks to the database with a service-role client that bypasses row-level security - there is no per-user auth at the database layer, because every caller is trusted server code (the MCP tools and the crons). src/lib/db.ts, and anything that touches the service role, is kept out of any bundle that ships to a browser by design.

What sits behind that service-role client differs by deployment. On the cloud service, it's Supabase: RLS is enabled on every table with zero policies, so isolation is enforced entirely in application code (the ownership checks described above), not by the database itself - which is exactly why those checks matter. On a self-hosted Docker stack there's no Supabase at all: it's the bundled Postgres plus PostgREST, and that PostgREST layer has no authentication configured because it's never exposed outside the Docker network in the first place - neither postgres nor postgrest publishes a port in docker-compose.yml, so nothing on your host or the internet can reach either container directly, regardless of firewall.

What DispatchSEO stores, and what it doesn't

It stores state about your SEO, not your site: tracked keywords and their rankings, Search Console snapshots (queries, clicks, impressions, positions), the suggestions queue with its reasoning, a registry of pages it built (URL, target keyword, publish date), backlink prospects, your site's product profile, and project settings.

It never stores your website's actual source or content. When the builder writes a guide or a tool, it works from a clone made locally (the in-stack builder on a self-hosted install) or on GitHub's own runners (GitHub Actions) - that clone lives outside DispatchSEO's own database entirely, which only ever records metadata about the page it produced, never the page's markup or copy. Your repo is the one and only source of truth for what you've published.

What leaves your machine on a self-hosted install

Nothing needs to reach in - that's why there's no domain or port forwarding required by default. The dashboard container is the only one that publishes a port at all, and it's bound to 127.0.0.1 by default, reachable from the machine running Docker and nowhere else, unless you deliberately widen it (giving it a real domain via the bundled Caddy profile, or setting DISPATCH_BIND=0.0.0.0 yourself, in front of a firewall you control).

Outbound, the stack calls out to exactly the services you've connected it to, and nothing else:

  • GitHub - to clone your repo, and to open and merge pull requests, using the token you provide.
  • Anthropic, OpenAI, or Cursor - the builder container runs your own coding agent headlessly: Claude Code on your subscription, Codex metered against your OpenAI key, or Cursor on its plan's API key. Only your project's chosen agent is called.
  • Google - the Search Console API, read-only, using your service account.
  • DataForSEO or SerpApi, if you've connected either, for keyword data.
  • Resend, if you've set up failure-alert email.
  • dispatchseo.com - once a day, the anonymous install ping described under Telemetry on a self-hosted install below: a random id and your version number, nothing else. Turn it off with DISPATCHSEO_TELEMETRY=off.

That's the complete list. Your Postgres data itself never leaves the machine - it isn't among the calls above, and it can't be, since nothing external ever reaches the database container to begin with.

Never expose the stack directly to the internet

Don't bind the dashboard to 0.0.0.0 (or forward its port) without also putting it behind a real domain and HTTPS. The VPS install gives you both together - a domain with automatic HTTPS via the bundled Caddy profile - specifically so you're never tempted to skip straight to a bare, unencrypted port facing the world. See Install on a VPS.

Google Search Console access

Both connection methods request the same scope: webmasters.readonly. Self-hosted installs use a service account (a Google robot identity you create once, covered in Google Search Console); the cloud version uses one-click OAuth instead. Either way, DispatchSEO can list the Search Console properties the account can see and read search analytics - queries, clicks, impressions, position - and nothing else. It cannot verify a new property, change settings, or modify anything in your Google account; the code never calls anything but the read-only endpoints, regardless of what permission level Search Console shows the service account as holding. See also how DispatchSEO uses Google data, written for Google's own OAuth reviewers.

Tokens and secrets

SecretLivesWhat it can doHow to rotate
MCP project keyEncrypted in your database; shown on Settings -> Project keyEverything MCP tools can do for that one project - nothing elseNo self-service regenerate yet; recreating the project issues a fresh one (see Troubleshooting)
CRON_SECRETYour .env (self-host) or the wizard-generated instance row; instance-wide, shown on Settings -> Cron keyTriggers this instance's own scheduled jobsEdit the value in .env and restart the stack (sh start.sh)
GitHub tokenEncrypted in your database; connected from the dashboard Home's Connect GitHub cardClones your repo, opens and merges pull requestsGenerate a new one on GitHub, paste it into the same card
Claude Code token (builder)Encrypted in your database, or CLAUDE_CODE_OAUTH_TOKEN in .env (env always wins)Runs your own Claude Code headlessly to build contentRun claude setup-token again and paste it into the credential box on Settings
OPENAI_API_KEY (builder, Codex projects)Encrypted in your database, or in .env (env always wins)Runs Codex headlessly - and it is a live metered billing credential, the one on this list that spends money directlyRevoke it at platform.openai.com/api-keys, create a fresh one, paste it into the same box
CURSOR_API_KEY (builder, Cursor projects)Encrypted in your database, or in .env (env always wins)Runs Cursor headlessly on your Cursor plan's usageRevoke it in your Cursor dashboard, create a fresh one, paste it into the same box

Treat all six as equal-weight, full access to their surface - none of them is safe to paste into a chat, a screenshot, or a public issue. The OpenAI key deserves one extra habit: when you stop using a project, revoke the key at the provider too, not just here.

Telemetry on a self-hosted install

Two different things share that word, and they're worth telling apart.

Product analytics: none. Self-hosted installs never initialize PostHog (the product analytics DispatchSEO's own team uses for the cloud service). The client-side init code checks for a project token before calling posthog.init() at all and skips entirely when it's unset; the server-side helpers no-op the same way. That token is never set on a self-hosted install: it isn't in docker-compose.yml or .env.docker.example, and the CI workflow that builds the public prebuilt image (ghcr.io/neozi12/dispatchseo) never passes it as a build argument either - so even the published image has no analytics token baked in. The Dockerfile also explicitly disables Next.js's own separate telemetry (NEXT_TELEMETRY_DISABLED=1). Vercel's page-view widget (<Analytics />) is present in every build but only sends data on Vercel's own infrastructure; on a self-hosted install it requests a script from your own server, gets a 404, and stops there.

The one exception: a daily install ping. Once a day, at 05:23 UTC, your install POSTs dispatchseo.com two fields - a random id generated on your own machine at first boot, and the version you're running. That is the whole payload: no domain, no email, no keywords, no site or Search Console data, no tokens, and the id isn't derived from any of them. It exists because nothing else can answer "how many people actually run this" - clone counts and image pulls measure download attempts, and one person re-running start.sh looks identical to ten separate installs. To turn it off, put DISPATCHSEO_TELEMETRY=off in your .env and re-run sh start.sh; nothing else changes. The code is src/lib/heartbeat.ts if you'd rather read it than take our word for it, and Environment variables documents both settings involved.

Reporting a vulnerability

Report it privately through GitHub's private vulnerability reporting (the "Report a vulnerability" button under the repo's Security tab) - never in a public issue. This is a solo-maintained project: you'll get an acknowledgment within a few days, and real vulnerabilities are prioritized over everything else, though there's no bug-bounty program. Only the latest main is supported, so keep your install up to date.

Cross-tenant findings on the hosted cloud service are in scope and worth reporting, including subtle ones (see the auth model above). Multi-user findings against a self-hosted deployment ("user A can see user B's data") are out of scope - there is only one user there by design. Full policy in SECURITY.md.