Secrets & Key Management

On this page

EmDash uses a small set of secrets across previews, comments, authentication, storage, and plugins. This page is the complete inventory: where each secret comes from, where it is stored, how to rotate it, and what breaks if it is lost.

Overview

SecretSourceStored inLost key impact
EMDASH_ENCRYPTION_KEYOperator (emdash secrets generate)Environment / Worker secret onlyEncrypted plugin secrets become unrecoverable (once encryption at rest ships)
Preview secretAuto-generated (env override)options table (emdash:preview_secret)Outstanding preview links stop working; new ones are fine
IP saltAuto-generated (env override)options table (emdash:ip_salt)Past comment rate-limit continuity resets
Session & API tokensGenerated per session/tokenSession store / database (hashes only)Nothing — plaintext is never stored
OAuth provider credentialsYou (Google/GitHub console)EnvironmentSign-in via that provider stops until replaced
Turnstile secretYou (Cloudflare dashboard)EnvironmentComment CAPTCHA verification fails
S3 credentialsYou (storage provider)Environment or configMedia upload/download fails until replaced
Plugin secretsYou (admin settings UI)Database (plugin settings / storage)Re-enter in the admin
CLI credentialsemdash login / emdash plugin publish device flows~/.config/emdash/auth.json (mode 0600)Run the device flow again
Registry CLI credentialsemdash-plugin atproto OAuth~/.emdash/oauth/, ~/.emdash/credentials.json (mode 0600)Log in again; identity lives at your PDS

The encryption key

EMDASH_ENCRYPTION_KEY is the site’s key for encrypting plugin secrets at rest. It is operator-provided and never stored in the database — the database only ever holds ciphertext, so a leaked database backup does not expose the key.

Generate one and set it as an environment variable (or Worker secret):

npx emdash secrets generate
# emdash_enc_v1_<43 base64url chars>

# Cloudflare:
wrangler secret put EMDASH_ENCRYPTION_KEY

The format is emdash_enc_v1_ followed by 32 random bytes as unpadded base64url. The key is validated at runtime startup; a malformed value logs an operator-facing error without taking down request paths.

Rotation

The variable accepts a comma-separated list of keys. The first entry is the primary and is used for new writes; all entries are tried for decryption. Every encrypted value is tagged with an 8-character key fingerprint (the kid, printable via emdash secrets fingerprint <key>), so the runtime picks the right key automatically.

To rotate: generate a new key, prepend it to the list (EMDASH_ENCRYPTION_KEY="new,old"), redeploy, and drop the old key once existing values have been re-encrypted.

Generated site secrets

Two secrets are generated automatically on first use and persisted in the options table, so they are stable across requests, deployments, and isolates. Generation is atomic — concurrent cold starts converge on one value.

Preview secret

Signs preview URLs (HMAC). Stored as emdash:preview_secret; 32 random bytes, base64url.

  • Override: set EMDASH_PREVIEW_SECRET (legacy alias: PREVIEW_SECRET) if you need the same secret across multiple processes or want to pin it for audit reasons. The environment always wins over the stored value.
  • Rotation: delete the emdash:preview_secret row (or change the env var) and redeploy. Impact: previously issued preview links stop validating. Nothing else breaks — a fresh secret is generated (or read from the env) on the next preview request.
  • If lost: nothing is unrecoverable. Preview links are short-lived by design.

See the preview guide for how preview URLs are built and verified.

IP salt

Salts the SHA-256 hash of commenter IP addresses (ip_hash on comments) used for comment rate limiting. Stored as emdash:ip_salt. Site-specific, so hashes are not correlatable across EmDash installs.

  • Override: set EMDASH_IP_SALT. For backward compatibility, EMDASH_AUTH_SECRET / AUTH_SECRET are also consulted — installs that historically derived the salt from those keep stable hashes.
  • Rotation: change the env var or delete the emdash:ip_salt row. Impact: new comment submissions hash to different values, so rate-limit counting restarts for everyone. Existing comments and their stored hashes are untouched.
  • If lost: no data loss. Only rate-limit continuity resets.

Session and API tokens

  • Sessions use Astro’s session store (Workers KV on Cloudflare, filesystem on Node). The cookie carries an opaque session ID; there is no signing secret to manage. Sign out to end a session, or clear the session store (e.g. the KV namespace) to force everyone to sign in again.
  • API tokens (ec_pat_, ec_oat_, ec_ort_ prefixes) are opaque 256-bit random values; only their SHA-256 hash is stored. The plaintext is shown once at creation. Rotate by revoking and re-creating in the admin.
  • Invite, magic-link, and recovery tokens are single-purpose, stored as SHA-256 hashes in auth_tokens, and time-limited (invites 7 days, magic links 15 minutes).

There is nothing to back up or rotate proactively: a database leak exposes only hashes, and every token can be revoked or reissued from the admin.

User-provided service credentials

Credentials for external services are read from the environment and never written to the database. Rotate them at the provider, update the variable, redeploy.

ServiceVariables
Google sign-inEMDASH_OAUTH_GOOGLE_CLIENT_ID, EMDASH_OAUTH_GOOGLE_CLIENT_SECRET (or unprefixed aliases)
GitHub sign-inEMDASH_OAUTH_GITHUB_CLIENT_ID, EMDASH_OAUTH_GITHUB_CLIENT_SECRET (or unprefixed aliases)
Marketplace publishing (CI)EMDASH_MARKETPLACE_TOKEN
Turnstile (comments)EMDASH_TURNSTILE_SECRET_KEY (or TURNSTILE_SECRET_KEY)
S3-compatible storageS3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_ENDPOINT, S3_BUCKET, S3_REGION

On Cloudflare, set these with wrangler secret put; locally, put them in .env. R2 via binding needs no credentials at all — access is granted by the binding in wrangler.jsonc, which is the recommended setup on Workers. See storage options.

Plugin secrets

Settings a plugin declares with type: "secret" (API keys for email providers, form CAPTCHAs, etc.) are entered in the admin UI and stored in the database — in the options table under plugin:<id>:settings:<key>, or in the plugin’s key-value storage. Whether a stored secret is echoed back to the admin UI is up to the plugin; well-behaved plugins return only a “value is set” flag instead of the secret itself (the bundled forms plugin does this).

  • Rotation: rotate the key at the provider and paste the new value into the plugin’s settings page. Takes effect immediately.
  • If lost: re-enter the value in the admin. Nothing else depends on it.

CLI credentials

The emdash CLI holds two kinds of credentials, both in ~/.config/emdash/auth.json (respecting XDG_CONFIG_HOME), created with owner-only permissions (0600):

  • Site tokensemdash login authenticates against your EmDash instance via an OAuth device flow and stores the resulting token keyed by instance URL. emdash logout removes it; per invocation, --token or EMDASH_TOKEN overrides the stored token.
  • Marketplace tokensemdash plugin publish authenticates to the EmDash Marketplace via a GitHub device flow and stores the resulting JWT keyed by marketplace:<origin>. For CI publishing, set EMDASH_MARKETPLACE_TOKEN instead — it takes priority over the stored credential.

Losing the file is harmless: run emdash login (or emdash plugin publish, which re-runs the device flow) again.

Plugin registry CLI credentials

The separate emdash-plugin CLI (package @emdash-cms/plugin-cli) targets the experimental AT Protocol registry. Publishing there is tied to your AT Protocol identity (your publisher DID) — the site itself holds no publishing credentials, and installs verify artifacts against checksums from release records attributed to that DID.

  • It authenticates via atproto OAuth. The OAuth session/state blobs live in ~/.emdash/oauth/, and the publisher identity (DID, handle, PDS) is cached in ~/.emdash/credentials.json; both are written with owner-only permissions.
  • In CI, provide identity via EMDASH_PUBLISHER_DID, EMDASH_PUBLISHER_HANDLE, and EMDASH_PUBLISHER_PDS; EMDASH_REGISTRY_URL overrides the registry host. Automated publish from CI still needs the OAuth session files in ~/.emdash/oauth/ on the runner — the env vars alone do not carry the OAuth session.
  • Rotating or revoking publishing access happens at your AT Protocol account (e.g. app passwords), not in EmDash. See Atmosphere auth.

Rotation quick reference

I want to…Do this
Rotate the encryption keyPrepend a new key: EMDASH_ENCRYPTION_KEY="new,old", redeploy, drop old later
Invalidate all preview linksDelete the emdash:preview_secret option row (or change the env override)
Reset comment rate-limit hashingChange EMDASH_IP_SALT (or delete the emdash:ip_salt option row)
Revoke a leaked API tokenAdmin → Users → API tokens → revoke, then create a replacement
Kill all sessionsClear the session store (Workers KV namespace / session directory)
Replace a provider credentialRotate at the provider, update the env var, redeploy
Replace a plugin API keyRotate at the provider, re-enter in the plugin’s admin settings