Skip to content

Manage secrets and keys

Use this inventory to decide which values belong in the runtime environment, which are generated into the database, and which are stored by plugins. Each section states how rotation affects a running site.

On Node.js, put runtime secrets in the hosting platform’s secret manager so they enter process.env when the process starts. For a Worker, use wrangler secret put. Do not put secret values in astro.config.mjs, wrangler.jsonc, or import.meta.env; Vite can embed build-time values in the server bundle.

SecretSourceStored inLost key impact
EMDASH_ENCRYPTION_KEYOperator (emdash secrets generate)Environment / Worker secret onlyNo current data impact; EmDash only checks its format
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)Runtime environmentMedia 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 does not currently encrypt plugin secrets or any other stored data. If the variable is set, EmDash checks its format during startup. A malformed value produces an operator-facing log message, but the site continues to handle requests.

The following command generates a correctly formatted value. Store it in the runtime environment or as a Worker secret if your deployment uses this variable.

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 value is operator-provided and is not stored in the database. Losing it has no current data-recovery impact because no stored data depends on it.

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; for local development, put them in .env. Wrangler reads either .dev.vars or .env, not both, and .dev.vars takes precedence when present. R2 through a binding needs no access-key variables because the binding grants runtime access. See media storage.

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