emDash starts from a stronger security position than most content management systems. There are no passwords to steal, plugin secrets are encrypted, and sandboxed plugins run in their own isolate. But several of the decisions that matter most are left to whoever deploys the site, and some of the defaults are only safe if you know what they assume.

This guide sets out what emDash secures for you, what it leaves to you, and the settings we'd check on every emDash site on Cloudflare. It is written for site owners, the agencies that build for them, and the developers who run the result. Every platform behaviour described here is taken from the emDash documentation or source for version 1.3. Where we use emDash Now as an example, we say so; the wider story of running this site is in What we learned running emDash in production.

What emDash secures for you

Before changing anything, it helps to know what is already handled. These come with every emDash install.

AreaWhat emDash does
Sign-inPasskeys by default: phishing-resistant, bound to your domain, no password stored
Sign-in abusePer-IP rate limits on the unauthenticated sign-in, sign-up and magic-link endpoints
TokensMagic links, invites and API tokens are stored only as SHA-256 hashes; magic links are single-use and expire after 15 minutes
Email scannersA magic link only signs you in after you select Continue, so scanners that pre-open links can't use it up
PermissionsFive roles, checked on the server for every action, not just hidden in the menu
Plugin secretsEncrypted at rest with AES-GCM; the admin only ever shows whether a secret is set
Request forgeryState-changing requests made with a session cookie need an X-EmDash-Request header or a matching origin
Headersnosniff, a strict-origin referrer policy, a restrictive Permissions-Policy and X-Frame-Options: SAMEORIGIN on every response; a strict policy on the admin

That is a good baseline. The rest of this guide covers what it leaves to you.

1. Setup and sign-in

Set up on the real domain

The first visit to the admin of an empty site opens the setup wizard, and the person who completes it becomes the administrator. Passkeys are then bound to the hostname where they were created.

  • Run setup on your production domain, not on a workers.dev URL or a temporary hostname. A passkey made on one address doesn't work on another.
  • Finish setup as soon as the first deploy is live. The emDash docs warn that a preview with an empty database shows a public setup wizard that anyone can complete; a new production deployment is in the same position until you finish it.
  • Set the address with EMDASH_SITE_URL, not siteUrl. A siteUrl in astro.config.mjs is built into every deployment, including previews, so passkeys on a preview get bound to the production domain. Put the production address in the EMDASH_SITE_URL variable and pass each preview its own.

Make sure you can't lock yourself out

  • Register at least two passkeys on different devices. Each user can have up to ten, and emDash won't let you delete your last one.
  • Configure an email provider. Magic-link sign-in, invites and account recovery all depend on it. Without email, a lost passkey on a single-admin site means editing the database by hand.
  • Have a second administrator, or at least a second person who can send you a recovery link.

Choose the sign-in mode deliberately

GitHub, Google and Microsoft sign-in can be added alongside passkeys, and they link to an existing account only when the provider supplies the same verified email address. Cloudflare Access is different: in production it replaces every other sign-in method and maps your identity provider's groups to emDash roles. Use Access when the team already has single sign-on; use passkeys otherwise.

If you self-host behind a reverse proxy, set trustedProxyHeaders. Without a trusted client IP, emDash skips the per-IP sign-in rate limits, because it has no safe address to count. On Cloudflare this is handled for you.

2. Roles and access

emDash has five roles, each including everything below it.

RoleCan do
SubscriberRead published content
ContributorCreate content and edit their own drafts; can't publish
AuthorCreate, edit and publish their own content
EditorManage all content, taxonomies, menus, bylines and widgets
AdminEverything, including content types, users, plugins, settings and backups

Give people the lowest role that does the job. Most writers need Author. Editors don't need Admin. Changing content types, installing plugins and exporting the whole site are Admin-only for good reason.

API tokens and AI assistants

Every emDash site runs an MCP server at /_emdash/api/mcp by default, so AI assistants such as Claude or ChatGPT can manage content; we used one to connect an agent to our own SEO work. It only accepts OAuth or personal access tokens, never a browser session, and every call is limited twice: by the token's scopes and by the user's role.

  • Clear the permissions a client doesn't need on the OAuth consent page. Every requested permission is selected by default.
  • Connect AI tools as a lower-role user, such as an Editor or Author account, not the owner's Admin account. A scope can never grant more than the user's role allows.
  • Prefer narrow scopes, for example content:read and content:write instead of admin. The site-transfer scopes can export every entry, including commenters' email addresses.
  • Revoke tokens you no longer use under Users, API tokens. Only a hash is stored, so a lost token is replaced, never recovered.
  • Turn the MCP server off with mcp: false if nobody uses it.

3. Secrets

The encryption key

EMDASH_ENCRYPTION_KEY encrypts every plugin setting declared as a secret, such as email-provider API keys and CAPTCHA secrets.

  • Generate it with npx emdash secrets generate and store it as a Worker secret before you run setup.
  • Keep a separate copy in a secret manager. It is not stored in the database, so no database backup contains it. Restore a database without its key and every encrypted setting is unreadable.

To rotate the key, put the new key first and the old one after a comma, re-save every plugin secret, check each integration, and only then remove the old key. emDash doesn't yet report which keys are still in use, so keep a list as you go. The full procedure, rehearsed on this site, is in Operating emDash safely.

Where each kind of secret belongs

SecretWhere it belongs
Encryption key, OAuth client secrets, comment Turnstile secretWorker secrets (wrangler secret put), or .env locally
Plugin API keysThe plugin's settings page in the admin, where they are encrypted
Public keys, such as a Turnstile site keyvars is fine; they ship to the browser by design
Anything else sensitiveNever in vars, the repository, the client bundle or import.meta.env

import.meta.env is inlined at build time, so a secret read that way ends up baked into the bundle. Native plugins should read secrets from process.env or cloudflare:workers at runtime.

Encryption at rest doesn't limit what a plugin does with a key once it has it. Before you paste a credential into a plugin, check what network and email access the plugin declares, and prefer a scoped key you can revoke.

4. Plugins

Plugins are where most CMS breaches start, and emDash's model is different enough from WordPress to be worth understanding.

Sandboxed pluginNative plugin
Runs inIts own isolate, through a sandbox runnerThe same process as your site
AccessOnly the capabilities it declares, approved at installEverything your site can access
NetworkOnly to its declared hostsAny host
Environment variables and bindingsNoneAll of them
Resource limitsCPU, subrequests and wall timeNone
Installed fromThe admin, through the plugin registrynpm and astro.config.mjs, with a redeploy

Two details catch people out:

  • No runner, no sandbox. On Cloudflare the sandbox needs the Workers Paid plan (what that costs) and a LOADER Worker Loader binding. Without it, sandboxed plugins simply don't load, and moving one into plugins: [] to make it work gives it full native access.
  • Capabilities are coarse. A plugin with content:write can change any content, not just its own. Read the capability list on the consent dialog, and treat an update that adds capabilities as a new decision.

Our rule: install fewer plugins, prefer sandboxed ones, and review a native plugin's code as you would your own. If you don't need the registry at all, leaving the sandbox switched off keeps both the attack surface and the configuration small. emDash Now runs only native plugins that we build or that emDash publishes, such as emDash Forms, and no sandbox.

5. Previews

Worker Previews make it easy to give every branch its own URL. They also make it easy to expose an admin panel or a copy of your data.

  • Never point a preview at production data. Give previews their own D1 database, R2 bucket and KV namespaces in the previews block of wrangler.jsonc. Previews don't inherit bindings or vars, so list everything explicitly.
  • Lock preview URLs. They are public by default. Put them behind Cloudflare Access, and give automated tests a service token rather than a person's login.
  • Use test keys. Give previews Cloudflare's always-pass Turnstile test keys and a test email audience, so a preview can never email a real subscriber.
  • Remember what doesn't run. Cron triggers don't run on previews, so scheduled posts, plugin cron tasks and scheduled backups need checking on production.

6. Public forms and comments

Every public endpoint attracts bots. emDash gives you the tools; switching them on is your job.

  • Turn on spam protection for every form. The first-party Forms plugin offers none, honeypot or Turnstile per form. Choose Turnstile for anything that emails a person or adds a contact.
  • Verify Turnstile on the server. Cloudflare is clear that the widget alone doesn't protect a form. Each token must be checked with the Siteverify API, lasts 300 seconds and can be used only once. The Forms plugin does this when its Turnstile keys are set; a custom form must do it itself.
  • Add rate limits at the edge. A Cloudflare WAF rate-limiting rule on the plugin endpoints (/_emdash/api/plugins/<plugin>/<route>) stops floods before they reach your Worker. emDash Now limits its subscribe, confirm and form-submit endpoints this way.
  • Confirm with a click, not a visit. If you build a double opt-in, make the confirmation link open a page with a button, as emDash does for magic links. Email scanners pre-open links and would otherwise confirm addresses nobody asked for. We cover this in owning your newsletter list.
  • Filter webhooks by sender. Email-provider webhooks can fire for every domain in a shared account. Act only on events for your own sending domain.
  • Moderate comments. Public comments go into a moderation queue and are rate-limited per salted IP hash. If comments aren't part of your plan, switch them off on the collection.

7. Headers and content security policy

emDash already sets the baseline headers listed above. Two additions are worth making.

  • HSTS. Add Strict-Transport-Security at the Cloudflare zone or in middleware, so browsers never fall back to plain HTTP.
  • A content security policy for the public site. Astro's security.csp adds hashed script-src and style-src directives as a <meta> tag. Browsers ignore frame-ancestors and reporting directives in a meta tag, so if you need those, send the policy as a response header from middleware instead.

Roll a new policy out as Content-Security-Policy-Report-Only first, collect the reports from every template, and enforce it only when there are none. Third-party scripts such as Turnstile, analytics and video embeds each need their hosts listed.

8. Data, backups and updates

Know what personal data you hold

Write down every place the site collects personal data, where it is stored and who else receives it, and make your privacy page match. On a typical emDash site that means form submissions in plugin storage, newsletter addresses, comment authors' names and email addresses, and anything an analytics script collects. Cloudflare Web Analytics is cookieless, which keeps a consent banner off the core site.

The Forms plugin stores submissions until you delete them. Export and delete old ones on a schedule that matches your privacy policy.

Back up what you can restore

A complete recovery point has four parts, and each needs its own copy:

01Database

D1 Time Travel keeps 30 days on Workers Paid and 7 on Free. Add an offsite SQL dump for anything longer, and record a bookmark before every migration.

02Media

Time Travel doesn't cover R2. Copy the bucket with read-only credentials and restore into an empty bucket, never over production.

03Encryption key

No database backup contains it. Keep the full key list in a separate secret manager, versioned with the backups it unlocks.

04Code version

The exact build that matches the database. Rolling back code doesn't roll back the database, so keep each deploy tagged to its commit.

emDash's JSON backups are useful for inspection, but emDash can't restore them. A site package from Site transfer is for moving a site, not recovering it, and it contains every entry and comment, including email addresses, so store it like a database backup. Rehearse a restore into an empty database before you need one, and never run a dump against the live database. How we set up, tested and timed each layer, including checked migrations in the deploy, is in Operating emDash safely.

Stay current

Keep emDash and its plugins up to date, and read the release notes for changes to stored data before you upgrade. Record a Time Travel bookmark before each upgrade that runs migrations. To report a vulnerability, use private reporting on the Security tab of the emDash GitHub repository, not a public issue.

Before you launch an emDash site0/10 done

How should you proceed?

  • Owners. You don't need to configure any of this yourself, but you should be able to ask for it. Ask for the checklist above at launch, and for proof that a restore has been rehearsed. If you're still deciding whether to adopt emDash at all, start with our production-readiness verdict.
  • Agencies. Most of this is the same on every site. Turn it into a launch template: a wrangler.jsonc with separate preview bindings, a WAF rule, a CSP rollout, a backup job and a key-handling procedure.
  • Developers. The traps are in the details: siteUrl versus EMDASH_SITE_URL, import.meta.env, a CSP header dropping X-Frame-Options, and a "sandboxed" plugin running natively. Check those first.