# Manage secrets and keys

> Where every EmDash secret lives, how to provide it, and what happens when you rotate or lose it.

Source: https://emdashcmseverything.com/official-docs/deployment/secrets/

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.

## Overview

| Secret                                             | Source                            | Stored in                                | Lost key impact                                 |
| -------------------------------------------------- | --------------------------------- | ---------------------------------------- | ----------------------------------------------- |
| [`EMDASH_ENCRYPTION_KEY`](#the-encryption-key)     | Operator (`emdash secrets generate`) | Environment / Worker secret only         | Encrypted plugin settings cannot be read until the matching key is restored |
| [Preview secret](#preview-secret)                  | Auto-generated (env override)     | `options` table (`emdash:preview_secret`) | Outstanding preview links stop working; new ones are fine |
| [IP salt](#ip-salt)                                | Auto-generated (env override)     | `options` table (`emdash:ip_salt`)        | Past comment rate-limit continuity resets       |
| [Session & API tokens](#session-and-api-tokens)    | Generated per session/token       | Session store / database (hashes only)   | Nothing — plaintext is never stored             |
| [OAuth provider credentials](#user-provided-service-credentials) | You (Google, GitHub, or Microsoft Entra console) | Environment                         | Sign-in via that provider stops until replaced  |
| [Turnstile secret](#user-provided-service-credentials) | You (Cloudflare dashboard)    | Environment                              | Comment CAPTCHA verification fails              |
| [S3 credentials](#user-provided-service-credentials) | You (storage provider)          | Runtime environment                      | Media upload/download fails until replaced      |
| [Plugin secrets](#plugin-secrets)                  | You (admin settings UI)           | Encrypted database setting               | Restore the matching encryption key or re-enter the value |
| [CLI credentials](#cli-credentials)                | `emdash login` device flow              | `~/.config/emdash/auth.json` (mode 0600) | Run the device flow again           |
| [Registry CLI credentials](#plugin-registry-cli-credentials) | `emdash-plugin` atproto OAuth | `~/.emdash/oauth/`, `~/.emdash/credentials.json` (mode 0600) | Log in again; identity lives at your PDS |

## The encryption key

`EMDASH_ENCRYPTION_KEY` encrypts plugin settings declared with `type: "secret"`. EmDash uses AES-GCM with the plugin ID and setting key as authenticated data. A malformed value produces an operator-facing startup message, and operations that need encrypted plugin settings fail closed. Unrelated site requests continue to work.

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.

```bash
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. Keep it in a secret manager and in a separate recovery backup.

> **Caution**
>
> A database backup does not contain `EMDASH_ENCRYPTION_KEY`. Restoring the database without every
> key referenced by its encrypted plugin settings leaves those values unreadable.

To rotate the key, prepend a new value and retain the old value after a comma:

```text title="Runtime secret"
EMDASH_ENCRYPTION_KEY=emdash_enc_v1_<new-key>,emdash_enc_v1_<old-key>
```

EmDash encrypts new and re-saved values with the first key. It uses the stored `kid` fingerprint to select an older key for reads. Re-save every plugin secret before removing the old key, then verify those integrations from a deployment that contains only the new key. EmDash does not currently report which key IDs remain in use, so keep an inventory of the credentials you resave and do not remove an old key until each integration has passed that verification.

## 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](https://emdashcmseverything.com/official-docs/guides/preview/) 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, then redeploy. 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). Adapters that do not configure a store, such as Vercel, need a [session driver](https://docs.astro.build/en/guides/sessions/#configuring-sessions) in `astro.config.mjs`; without one, sign-in fails with an error explaining that a session driver is required. 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.

| Service                | Variables                                                                                     |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| Google sign-in         | `EMDASH_OAUTH_GOOGLE_CLIENT_ID`, `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET` (or unprefixed aliases)  |
| GitHub sign-in         | `EMDASH_OAUTH_GITHUB_CLIENT_ID`, `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` (or unprefixed aliases)  |
| Microsoft sign-in      | `EMDASH_OAUTH_MICROSOFT_CLIENT_ID`, `EMDASH_OAUTH_MICROSOFT_CLIENT_SECRET`, `EMDASH_OAUTH_MICROSOFT_TENANT_ID` (or unprefixed aliases) |
| Turnstile (comments)   | `EMDASH_TURNSTILE_SECRET_KEY` (or `TURNSTILE_SECRET_KEY`)                                     |
| S3-compatible storage  | `S3_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](https://emdashcmseverything.com/official-docs/deployment/storage/).

On Node, EmDash reads the Turnstile secret key from the server's process environment when a request arrives, and neither `astro dev` nor the built server loads `.env` into it. Set the key in your host's environment, export it in the shell that runs `astro dev`, or start the built server with `node --env-file=.env ./dist/server/entry.mjs`.

## Plugin secrets

Settings a plugin declares with `type: "secret"` (API keys for email providers, form CAPTCHAs, etc.) are entered in the admin UI and encrypted in the `options` table under `plugin:<id>:settings:<key>`. The admin receives only whether a secret is set, even when the matching encryption key is unavailable, so an administrator can replace an unreadable credential. Plugin code reads the plaintext through `ctx.settings` inside its isolated runtime. Values stored outside a declared settings schema, including arbitrary plugin KV and state entries, do not use this encryption path.

> **Caution**
>
> Encryption at rest does not limit what the installed plugin can do with a value you submit to its
> settings form. Review the plugin's declared network and email access, and prefer scoped, revocable
> credentials.

- **Credential rotation:** rotate the credential at the provider and paste the new value into the plugin's settings page. The save writes a new encrypted envelope.
- **Plaintext migration:** secrets stored by an earlier EmDash release remain readable. Save each value again to encrypt it.
- **If the encryption key is lost:** restore the matching `EMDASH_ENCRYPTION_KEY` from the separate key backup. If no copy exists, replace every affected credential at its provider and enter the replacements after configuring a new encryption key.

## CLI credentials

The `emdash` CLI stores site tokens in `~/.config/emdash/auth.json` (respecting `XDG_CONFIG_HOME`) with owner-only permissions (`0600`). `emdash login` authenticates against your EmDash instance via an OAuth device flow and stores the token keyed by instance URL. `emdash logout` removes it; per invocation, `--token` or `EMDASH_TOKEN` overrides the stored token.

Losing the file is harmless: run `emdash login` 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](https://emdashcmseverything.com/official-docs/guides/atmosphere-auth/).

## Rotation quick reference

| I want to…                              | Do this                                                                        |
| --------------------------------------- | ------------------------------------------------------------------------------ |
| Rotate plugin-setting encryption        | Prepend the new key, re-save plugin secrets, then remove the old key           |
| Invalidate all preview links            | Delete the `emdash:preview_secret` row (or change the env override), redeploy |
| Reset comment rate-limit hashing        | Change `EMDASH_IP_SALT` (or delete the `emdash:ip_salt` option row), redeploy |
| Revoke a leaked API token               | Admin → Users → API tokens → revoke, then create a replacement                |
| Kill all sessions                       | Clear the session store (Workers KV namespace / session directory)            |
| Replace a provider credential           | Rotate at the provider, update the env var, redeploy                          |
| Replace a plugin API key                | Rotate at the provider, re-enter in the plugin's admin settings               |
