Security
How Searchkeep is protected: Cloudflare Access, Managed OAuth for MCP clients, JWT re-verification on every request, readiness states and locking until ready.
Security model
Searchkeep holds search data about your business and credentials for three APIs, so it is built to be safe without you having to get anything subtle right:
- No application logins. Authentication is delegated entirely to Cloudflare Access. Searchkeep contains no password storage and no OAuth server code.
- Defense in depth. Access blocks unauthenticated traffic at the edge, and the Worker independently verifies Access’s signed JWT on every request.
- Fail closed. Until Access, Managed OAuth and a tight policy are all confirmed, no data is served and no sync runs.
- Least privilege. Every credential is read-only by default. The one write action is opt-in.
- Nothing leaves your account. No telemetry, no vendor backend.
Cloudflare Access
A single self-hosted Access application covers the Worker’s whole hostname. People using the dashboard sign in through Access with whatever identity provider you configured (one-time PIN by email works out of the box) and then carry Access’s session cookie.
The policy should allow only your own email or a short list of specific emails. Searchkeep reads your Access
application and policies through the Cloudflare API (with Access: Apps and Policies Read) and refuses
to serve data if a policy lets in Everyone, a public email domain such as gmail.com, outlook.com or icloud.com, anyone who can log in with an identity provider, every IP address
or a whole country, or if it uses Bypass or Service Auth. The result of that check is cached for up to five minutes; GET /api/status?refresh=1 re-checks immediately.
Managed OAuth for MCP
MCP clients cannot use a browser cookie, so the Access application has Managed OAuth turned on. Access then acts as a complete OAuth 2.1 authorization server for your hostname:
- Discovery. Clients find the authorization server from the MCP endpoint’s metadata.
- Dynamic client registration. Each client registers itself; you never create or paste a client ID.
- PKCE. Authorization codes are bound to the client that requested them.
- Same policy. The OAuth login is an Access login, so it is governed by the same policy as the dashboard.
If Managed OAuth is off, MCP clients would be redirected to an HTML login page they cannot complete. Searchkeep detects
this through the API and reports oauth_disabled instead of failing mysteriously.
JWT re-verification
Access adds a signed JWT to every request it lets through. The Worker does not trust that a request came through Access; it checks:
GET /api/sites HTTP/1.1
Host: searchkeep.example.com
Cf-Access-Jwt-Assertion: eyJhbGciOiJSUzI1NiIsImtpZCI6Ij...
# The Worker verifies, on every request:
# signature against https://<ACCESS_TEAM_DOMAIN>/cdn-cgi/access/certs
# iss == https://<ACCESS_TEAM_DOMAIN>
# aud contains ACCESS_AUD
# exp / nbf within the allowed clock skewThe keys are fetched from your team domain’s JWKS endpoint and cached. Because of this check, a request that reaches the Worker by any other route (a leftover workers.dev URL, a preview URL, a misconfigured route) carries no valid JWT and gets nothing.
The verification is a generic OIDC JWT check (issuer, JWKS URL, audience), so other identity-aware proxies can be supported later without changing the rest of the app.
Readiness states
Readiness is computed from each request’s JWT plus a cached API check of your Access configuration. It is not a setting you flip, so if someone later disables Access or loosens the policy, Searchkeep locks itself again automatically.
| State | Condition | Dashboard | MCP and /api | Cron |
|---|---|---|---|---|
1no_access | The request has no valid Cf-Access-Jwt-Assertion, or ACCESS_TEAM_DOMAIN / ACCESS_AUD are not set. | Every page shows the public setup guide. | /api/* returns 403 with "code":"locked","state":"no_access". /mcp returns a JSON-RPC error that explains the lock. | Does not sync. |
2oauth_disabled | The JWT is valid, but the Access application does not have Managed OAuth enabled, or Searchkeep cannot read the application with CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. | Status and setup checks only. | Refused with 403 (/api/status still answers). /mcp returns a JSON-RPC error. | Does not sync. |
3policy_too_broad | A policy lets in more than specific people: Everyone, a public email domain such as gmail.com, any identity-provider login, every IP, a whole country, Bypass or Service Auth. | Status and setup checks only. | Refused with 403 (/api/status still answers). /mcp returns a JSON-RPC error. | Does not sync. |
4ready | All checks pass. | Fully available. | Fully available. | Syncs every 15 minutes, in small batches. |
Locked until ready, including the cron
Cron Triggers are invoked by Cloudflare internally, carry no JWT and do not pass through Access. To keep the sync from
running on an unprotected deployment, the Worker records a ready flag in D1 the first time an
authenticated request is fully ready, and clears it whenever an authenticated request sees a failing
check. The cron syncs only while the flag is set, and every pass (every 15 minutes) re-reads the Access application
through the API first: if Access was turned off or the policy loosened, it clears the flag and does not sync.
Anonymous requests can never set or clear the flag.
Public endpoints
Three paths are served without a JWT, and none exposes data:
| Path | Returns |
|---|---|
/setup | Generic setup steps. No data, no configuration values, no source status, and no action that writes anything. |
/healthz | {"ok":true} and nothing else. Useful for uptime checks. |
/robots.txt | Disallow: / for every crawler. |
Since Access covers the whole hostname, these are behind the Access login too. Do not add a Bypass or Service Auth
policy to the Searchkeep application to open them up: Searchkeep treats either as too broad and locks itself
(policy_too_broad). Everything else, /, /api/* and /mcp, needs a
valid Access JWT; without one, / serves the same setup guide, /api/* returns 403 and /mcp returns a JSON-RPC error.
Credentials
- All credentials are Worker Secrets: encrypted at rest, write-only after creation, never in the repository.
- Searchkeep never logs secret values;
setup_statusreports presence only. - The GSC service account is a Restricted user unless you opt into writes.
- The Cloudflare token has read permissions only.
- Configuration problems are reported by
setup_statuswithout ever echoing a secret’s value. - The Bing API key cannot be scoped down; it can submit URLs and remove sites. Treat it like a password and rotate it if exposed.
- Third-party API access tokens (for example Google’s OAuth access token) are cached in memory only.
Reporting a vulnerability
Please report security issues privately through GitHub security advisories on the repository, not in public issues. Include the version, a description and steps to reproduce.