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, 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 withDISPATCHSEO_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
| 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 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 directly | Revoke 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 usage | Revoke 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.
DispatchSEO