Skip to content

Secrets & Key Management

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.

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

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):

Terminal window
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.

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.

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.

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.

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.
  • 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.

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.

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.

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.

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.
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