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 point | Self-hosted | Cloud (dispatchseo.com) | | --- | --- | --- | | Dashboard | One shared password | Supabase Auth (email/password or Google) | | Crons | Authorization: Bearer CRON_SECRET | Same | | MCP server | Per-project bearer token | Same |

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 - the builder container runs your own Claude Code headlessly, on your own subscription.
  • 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.

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

| Secret | Lives | What it can do | How to rotate | | --- | --- | --- | --- | | MCP project key | Encrypted in your database; shown on Settings -> Project key | Everything MCP tools can do for that one project - nothing else | No self-service regenerate yet; recreating the project issues a fresh one (see Troubleshooting) | | CRON_SECRET | Your .env (self-host) or the wizard-generated instance row; instance-wide, shown on Settings -> Cron key | Triggers this instance's own scheduled jobs | Edit the value in .env and restart the stack (sh start.sh) | | GitHub token | Encrypted in your database; connected from the dashboard Home's Connect GitHub card | Clones your repo, opens and merges pull requests | Generate 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 content | Run claude setup-token again and paste it into Home's Turn on automatic builds card |

Treat all four as equal-weight, full access to their surface - none of them is safe to paste into a chat, a screenshot, or a public issue.

No telemetry on a self-hosted install

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.

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.