# xecret — complete documentation > Open-source secret management for developers. This file is every page of the xecret documentation concatenated in reading order. The index, with one link per page, is at https://xecret-staging.playxoft.com/llms.txt. If you are an agent about to work with xecret rather than read about it, the skill at https://xecret-staging.playxoft.com/skill.md is one page covering the commands, the scoping rules, the errors and the conventions this tool expects. It defers to the pages below for detail. --- # What is xecret? Source: https://xecret-staging.playxoft.com/docs/what-is-xecret Summary: A plain-English explanation of the problem xecret solves, who it is for, and how it compares to keeping a .env file. Updated: 2026-08-16 If you have ever pasted a database password into Slack, or asked a teammate to "send me your `.env`", this page is for you. It assumes you know what an environment variable is and nothing beyond that. ## The problem, in one paragraph Applications need configuration they cannot publish: database passwords, API keys, signing secrets. The traditional home for these is a file called `.env` sitting in your project folder, ignored by Git so it never reaches the repository. That works perfectly right up until a second person joins. Then the file has to travel — over chat, over email, over a USB stick — and from that moment nobody can answer three questions that matter: - **Who has a copy?** Every place the file was pasted still has it. - **Is mine current?** A teammate rotated the API key on Tuesday. Your file says otherwise, and you find out when production breaks. - **What happened?** Somebody's laptop was stolen. Which credentials were on it, and did anyone read them before you rotated? `.env` files are not insecure because they are files. They are insecure because they are *copies*, and a copy is something you have stopped tracking. ## What xecret does instead xecret stores each environment's variables once, on a server, encrypted. Your app never reads them from a file — a command-line tool fetches them and hands them to your program as it starts: ```bash xecret run -- npm run dev ``` That command asks the server for the current secrets, puts them into the environment of the process it starts, and runs your app. Your code is unchanged: it still reads `process.env.DATABASE_URL`, or `os.Getenv`, or whatever your language calls it. What changed is where the value came from. Three consequences follow, and they are the whole product: | | | |---|---| | **No file on disk** | Nothing to leak, nothing to accidentally commit, nothing to forget to delete. | | **One current version** | Rotate a key in the dashboard and every teammate's next `xecret run` has it. There is no "your copy". | | **Every read is recorded** | The audit log names who read which environment and when. When a laptop goes missing, that question has an answer. | ## Who this is for xecret is aimed at people who write and ship software: solo developers who are tired of `.env.example` drifting out of date, and teams who need production credentials to be something an admin grants rather than something everyone inherits. It is *not* a password manager for humans — it does not autofill your bank login. It is for credentials that programs use. ## How this compares to what you may be using now | Approach | What it costs you | |---|---| | **`.env` files** | Copies you cannot track, no rotation story, no audit trail. Free and fine for a solo hobby project. | | **CI provider secrets** (GitHub Actions secrets, etc.) | Fine for CI, but they do not help on your laptop, and each provider is a separate silo you keep in sync by hand. | | **Cloud KMS / Secrets Manager** (AWS, GCP) | Powerful, and built for infrastructure rather than for `npm run dev`. Usually needs SDK changes in your app. | | **xecret** | One store for laptop, CI and production, injected without code changes, audited. You trust a server with your secrets — see below. | ## The honest part xecret uses **server-side envelope encryption**. Each environment has its own encryption key; that key is itself encrypted under a root key the server holds. Values are decrypted inside a single request handler, and every decryption is written to an append-only log. That design means the service **can** technically decrypt your secrets. This is the same model Doppler uses, and it is what makes team sharing, CI tokens and browser-side import work without every user performing a key exchange. If you need a provider that *cannot* read your secrets even in principle, you need a zero-knowledge product, and you should know that before you migrate rather than after. The full reasoning is in [what xecret can and cannot see](security/trust-model.md). > **Important** — xecret is currently pre-alpha. The features described in this > documentation are built, but the service has not yet run against production > infrastructure. Do not put credentials you cannot afford to rotate into it > yet. ## What to read next - [Quickstart](quickstart.md) — from sign-up to a running app, about five minutes. - [Core concepts](concepts.md) — the five words the rest of these pages use. - [What xecret can and cannot see](security/trust-model.md) — the security model in full, before you trust it with anything real. --- # Quickstart Source: https://xecret-staging.playxoft.com/docs/quickstart Summary: From sign-up to running your app with injected secrets in about five minutes, including your first CI job. Updated: 2026-08-16 Five steps, about five minutes. Everything here also works against a self-hosted deployment — set `XECRET_API_URL` and the steps are identical. If a word here is unfamiliar, [core concepts](concepts.md) defines all of them in one page. ## 1. Create an account Sign up with Google or with an email address and password. On your first sign-in xecret creates your **personal organisation** — a workspace with just you in it. Set up your vault, then create your first **project**: one project per application, and it starts with three **environments** — `development`, `staging` and `production`. Each environment gets its own encryption key at the moment it is created — generated in your browser and sealed to you, so the server never holds it. A secret stored in `staging` is encrypted under a different key from the same secret in `production`. ## 2. Import the `.env` you already have Open your project in the dashboard, pick an environment, and drag your `.env` file into the import dialog. The file is parsed **in your browser**. A dry-run preview shows exactly what will be created, renamed or skipped before a single value is sent anywhere — which means a pasted blob of production credentials never becomes a request body you have to trust us with. Multiline PEM blocks, `export ` prefixes, CRLF line endings and trailing comments all parse the way you would hope. JSON and YAML work too. > **Tip** — No `.env` to import? Add one secret by hand to see the flow: > `DATABASE_URL` is the usual first. ## 3. Install the CLI ```bash # macOS brew install playxoft/tap/xecret # Linux, WSL, CI — verifies the release checksum before unpacking curl -fsSL https://xecret.playxoft.com/install.sh | sh # anywhere you already have Node npm install -g @playxoft/xecret ``` Windows, other package managers and manual installs are covered in [installing the CLI](install.md). Check it worked: ```bash xecret version ``` ## 4. Sign in and point at your project ```bash xecret login # opens your browser; approve this device xecret init # pick a project and environment; writes .xecret.yaml ``` `xecret login` opens a browser page where you approve the device by name. The credential it receives is stored in your operating system's keychain — macOS Keychain, Windows Credential Manager, or Secret Service on Linux — never in a dotfile you might commit or sync. `xecret init` writes a file called `.xecret.yaml`: ```yaml project: my-app environment: development ``` Two slugs and no secrets. **Commit it.** Everyone who clones the repository is then one command away from a working app. ## 5. Run your app ```bash xecret run -- npm run dev ``` Everything after `--` is your command, run exactly as you would have run it yourself. The difference is that its environment already contains every secret in the environment you selected. What happens under the hood: 1. The CLI resolves which project and environment you mean (flags, then `.xecret.yaml`). 2. It asks the server for that environment; the server decrypts the values and returns them over TLS. 3. The CLI starts your command with those values in its environment, forwards signals such as `Ctrl-C`, and exits with your command's exit code. Secrets never touch disk, never appear in `ps` output, and never enter your shell history. Every read is written to the audit log. If the API is unreachable, an encrypted local cache answers instead — loudly, printing its age to stderr — so an outage on our side does not stop your `npm run dev`. See [the offline cache](cli/offline-cache.md). Now delete your `.env` file, and make sure `.env*` is in `.gitignore`. ## 6. Put it in CI Mint a **service token** in the dashboard, under *Tokens → New service token*. A service token is pinned to exactly one project and one environment, is read-only by default, and is shown to you exactly once — copy it straight into your CI provider's secret store. ```yaml # GitHub Actions - uses: playxoft/xecret@v1 - run: xecret run -- npm run build env: XECRET_TOKEN: ${{ secrets.XECRET_TOKEN }} ``` No login, no `.xecret.yaml`, no configuration: the token knows its own scope. GitLab, CircleCI and Docker recipes are in [secrets in CI](guides/ci.md). ## Where to go next | If you want to… | Read | |---|---| | Understand the words used above | [Core concepts](concepts.md) | | See every command and flag | [CLI command reference](cli/commands.md) | | Wire this into your framework | [Next.js](guides/nextjs.md) · [Node.js](guides/nodejs.md) · [React / Vite](guides/react-vite.md) · [Go](guides/go.md) | | Add your team | [Teams, roles and access](guides/teams.md) | | Know exactly what we can see | [Trust model](security/trust-model.md) | | Fix something that went wrong | [Troubleshooting](troubleshooting.md) | --- # Core concepts Source: https://xecret-staging.playxoft.com/docs/concepts Summary: Organisations, projects, environments, secrets, versions and tokens — the vocabulary every other page assumes, defined once. Updated: 2026-08-16 Six words carry the whole product. Learn them here and every other page reads easily. ## The hierarchy ```text Organisation acme — a company or a person; billing and members live here └── Project checkout-api — one application └── Environment production — one stage of that application └── Secret STRIPE_SECRET_KEY — one name, and a history of values └── Version v4 — one value, immutable once written ``` Everything is addressed by **slug** — the lower-case, hyphenated name you see in URLs — never by a numeric id. That is a security property rather than a style choice: a slug only means something inside its parent, so the path `/acme/checkout-api/production/STRIPE_SECRET_KEY` carries the whole ownership chain and every lookup has to walk it. ### Organisation A workspace. It owns projects, members, billing and audit history. You get a personal one on sign-up, and can create more (a company, a client) or be invited to somebody else's. Slugs are 25 characters at most and drawn from `a–z`, `0–9` and hyphens. A handful of words — `api`, `app`, `admin`, `settings`, `members` and similar — are reserved because they would collide with application routes. ### Project One application. If it has its own repository, it is probably its own project. ### Environment One stage of one project: `development`, `staging`, `production`, or whatever your deployments are actually called. Three are created for you with each new project, and you can add more. Two things make an environment more than a folder: - **It has its own encryption key**, created in the same transaction as the environment itself. - **One of them may be marked production**, which changes who can reach it by default. See [access levels](#access-levels) below. ### Secret A named value inside an environment — `DATABASE_URL`, `STRIPE_SECRET_KEY`. Names follow the same rule your shell does: letters, digits and underscores, and never starting with a digit. Up to 256 characters. `MY_KEY` is valid; `my-key` and `2FA_SECRET` are not. A secret may also declare a **value type**, which is validated on every write: `string` (the default, accepts anything) · `boolean` · `int` · `decimal` · `email` · `url` · `date` · `datetime` · `json` · `yaml` · `xml` · `ulid` · `uuidv4` · `uuidv7` The failure this prevents is not a typo. It is the deploy where `PORT` was saved as `3o30`, the service refused to start at 02:00, and the value looked right to everyone who read it. ### Version Every write appends. Updating `DATABASE_URL` creates version 5; version 4 is still there, still readable by anyone who may read the environment, and still restorable. Nothing is ever overwritten in place. Writing a value identical to the current one is a no-op — detected without decrypting anything — so a script that re-applies the same configuration does not fill your history with noise. Renaming a secret or changing its note does **not** create a version: declaring a type is not a rotation, and neither is a rename. ## Who can do what Two independent gates decide every request, and both must pass. ### Roles Your role in an organisation says what *class* of action is available to you at all. There are four, ordered `viewer` < `developer` < `admin` < `owner`. | Role | Typically | |---|---| | `owner` | Created the organisation. The only role that can delete it. | | `admin` | Manages members, tokens, projects and the audit log. | | `developer` | Reads and writes secrets; cannot manage members. | | `viewer` | Reads. Cannot write anywhere. | Nobody can hand out a role above their own — an admin can appoint another admin, but only an owner can create an owner. ### Access levels Your role's capability is one gate; the level you hold on *this particular* project or environment is the other. Levels are ordered `none` < `read` < `write` < `admin`. Where no grant says otherwise, your role supplies a default: | Role | Non-production | Production | |---|---|---| | `owner` | admin | admin | | `admin` | admin | admin | | `developer` | write | **none** | | `viewer` | read | **none** | **Production is deny-by-default for developers and viewers.** A developer who needs production must be granted it explicitly by somebody who manages members, and that grant lands in the audit log with a name attached. The alternative — production behaving like every other environment until somebody remembers to lock it down — makes the safe state the one that requires work. A grant may target a whole project or a single environment, and the more specific one wins. `none` always denies, whatever the role default would have said. Full detail, including the effective-access preview: [teams, roles and access](guides/teams.md). ## Credentials Three ways to prove who you are. A request presents exactly one. | Credential | Looks like | Who uses it | |---|---|---| | **Session** | a cookie | You, in the dashboard | | **CLI token** | `xct_…` | `xecret login` on your machine | | **Service token** | `xst_…` | CI jobs and containers | A CLI token *acts as you* — it carries your role and your grants, and nothing more. A service token is not a person: it is pinned to one organisation, one project and one environment, is read-only unless you say otherwise, and can never delete a secret. See [tokens](api/tokens.md). ## The audit log Every mutation, every decryption, and **every denial** is recorded, in an append-only table nobody can edit. A system that records only what succeeded cannot detect an attack in progress. The log names the actor — a person, a CLI token, or a service token — never "whoever created the token". A CI write is recorded as the act of that token. See [the audit log](security/audit-log.md). ## Two rules the CLI never breaks Worth knowing before you read any command page: 1. **A secret value is never written** to stdout, stderr, a log, a temporary file, or a process argument — except by `xecret pull` and `xecret secrets get --plain`, whose stated purpose is producing one. 2. **Credentials live in the OS keychain**, never in a dotfile. Where no keyring exists, a `0600` file under `~/.xecret/` is used, and the CLI tells you it is doing that. ## What to read next - [Quickstart](quickstart.md) if you have not run anything yet. - [The CLI](cli.md) for how these concepts appear on the command line. - [Teams, roles and access](guides/teams.md) to add people. --- # Installing the CLI Source: https://xecret-staging.playxoft.com/docs/install Summary: Install the xecret command-line tool on macOS, Linux, Windows, from npm, in Docker and in CI — with checksum verification and upgrade instructions. Updated: 2026-08-18 The `xecret` binary is a single static executable written in Go (`CGO_ENABLED=0`), for macOS, Linux and Windows on both amd64 and arm64. It has no runtime dependencies — no Node, no Python, no shared libraries — which is what lets it drop into a scratch container. The CLI is MIT-licensed, unlike the server, so it can live inside your Docker images and CI pipelines without licence friction. ## macOS ```bash brew install playxoft/tap/xecret ``` Upgrade with `brew upgrade xecret`. ## Linux, WSL and CI ```bash curl -fsSL https://xecret.playxoft.com/install.sh | sh ``` The script detects your OS and architecture, downloads the matching release archive, **verifies its checksum**, and installs to `/usr/local/bin` (or `~/.local/bin` if that is not writable). > **Note** — Piping a script to a shell deserves scepticism. The security > boundary here is the checksum verification inside the script, not the URL: > the URL redirects to the canonical installer, and you are welcome to fetch it > and read it before running it. > > ```bash > curl -fsSL https://xecret.playxoft.com/install.sh -o install.sh > less install.sh > sh install.sh > ``` ### A specific version ```bash curl -fsSL https://xecret.playxoft.com/install.sh | XECRET_VERSION=v1.2.0 sh ``` The version is an environment variable, not a flag — the installer reads `XECRET_VERSION` and parses no arguments at all, so anything after `-s --` is silently discarded and you get the latest release instead. Pinning a version is the right default in CI: an installer that always fetches the newest release makes your pipeline depend on our release schedule. ## npm ```bash npm install -g @playxoft/xecret ``` Or as a dev dependency, so everyone on the project gets the same version from the lockfile: ```bash npm install --save-dev xecret npx @playxoft/xecret run -- npm run dev ``` `xecret` is a small wrapper whose `optionalDependencies` are six packages holding one executable each — npm installs exactly the one that matches your machine and skips the other five. > **Note** — There is no `postinstall` script and nothing is downloaded during > the install. That is deliberate. `npm ci --ignore-scripts` is a sensible thing > to run, and a secret manager whose installer silently broke under it would be > teaching people to turn that protection off. Shipping the binary *as* a > package also puts it under npm's own `integrity` hash in your lockfile, > checked on every install, instead of behind a download our own code would have > to verify. The published packages carry [npm provenance](https://docs.npmjs.com/generating-provenance-statements): the registry can show you the workflow run and the commit each tarball was built from. The binaries inside are the same ones the GitHub release serves, verified against the signed `checksums.txt` before they were packaged. One trade-off worth knowing: installed this way, a Node process sits in front of the binary for the life of each command. It costs a few milliseconds and one process. For `xecret run -- npm run dev`, which runs for hours, Homebrew or the install script are the better choice — they give you the executable and nothing else. ## Windows Download the `windows_amd64` or `windows_arm64` archive from the releases page, unzip it, and put `xecret.exe` somewhere on your `PATH`. `npm install -g @playxoft/xecret` works on Windows too, and is usually the shortest route if you already have Node. The CLI stores its credential in **Windows Credential Manager**. PowerShell, `cmd.exe` and WSL all work; under WSL you are using the Linux binary and the Linux keyring rules apply. ## Docker Copy the binary out of the published image rather than installing it at build time — no package manager, no network fetch, and a version you pinned: ```dockerfile FROM ghcr.io/playxoft/xecret:1.2.0 AS xecret FROM node:22-slim COPY --from=xecret /usr/local/bin/xecret /usr/local/bin/xecret ``` `ghcr.io/playxoft/xecret` is a multi-arch (amd64 and arm64) distroless image carrying the same static binary the installer would have fetched. The image tags carry the version *without* the leading `v` — `1.2.0`, not `v1.2.0` — because that is what GoReleaser templates from, the same way the archive names do. Pin it: an unpinned install resolves the latest release at build time, so the same Dockerfile would produce a different binary on every rebuild. Full patterns, including why you should not bake secrets into layers, are in [Docker](guides/docker.md). ## Verify the install ```bash xecret version ``` prints the version, the commit it was built from, the build date, and your platform. If the shell answers `command not found`, the install directory is not on your `PATH` — reopen your terminal, or add it: ```bash export PATH="$HOME/.local/bin:$PATH" ``` ## Pointing at a self-hosted deployment The binary ships with `https://xecret.playxoft.com` compiled in as its default server. To use your own: ```bash xecret login --api-url https://secrets.your-company.com ``` The URL is stored with the credential, so you only pass it once. In CI, where there is no stored login, set `XECRET_API_URL` beside `XECRET_TOKEN`. See [configuration](cli/configuration.md). ## Upgrading | Installed with | Upgrade with | |---|---| | Homebrew | `brew upgrade xecret` | | Install script | Run the same command again; it replaces the binary in place. | | Manual download | Download the new archive and replace the binary. | Your stored credential survives an upgrade. It lives in the OS keychain, not beside the binary. ## Uninstalling ```bash xecret logout # revokes this device's credential server-side # and wipes the encrypted offline cache ``` Then remove the binary (`brew uninstall xecret`, or delete it from `/usr/local/bin`) and, if you want nothing left behind, the `~/.xecret/` directory. Running `logout` first matters: deleting the binary leaves the credential valid on the server. If you have already deleted it, revoke the device from the dashboard under *Tokens → Your devices*. ## Next - [The CLI](cli.md) — what it does and the rules it follows. - [Quickstart](quickstart.md) — sign in and run something. --- # The xecret CLI Source: https://xecret-staging.playxoft.com/docs/cli Summary: What the command-line tool does, the two rules it never breaks, how output is split between stdout and stderr, and where to find each command. Updated: 2026-08-16 The `xecret` binary is how secrets reach your programs. One command does the important thing: ```bash xecret run -- npm run dev ``` Everything else exists to support it. ## Two rules that govern everything Both are structural, not aspirational — they are properties of how the binary is written, and the rest of this documentation depends on them being true. 1. **A secret value is never written** to stdout, stderr, a log file, a temporary file, or a process argument. The exceptions are `xecret pull`, `xecret export` and `xecret secrets get [--version N] --plain`, whose entire purpose is producing a value, and which write it to stdout raw — or, for `export`, to one named file created `0600` — and nowhere else. 2. **Credentials live in the OS keychain**, never in a dotfile you might commit or sync — macOS Keychain, Windows Credential Manager, Secret Service on Linux. Where no keyring exists, a `0600` file under `~/.xecret/` is used and the CLI says so on stderr rather than doing it quietly. ## What the commands are for | Group | Commands | Page | |---|---|---| | Signing in | `login` `logout` `whoami` `orgs` | [Commands](cli/commands.md#authentication) | | Pointing at a project | `init` `projects` `environments` | [Commands](cli/commands.md#project-setup) | | Working with secrets | `secrets list` `get` `set` `annotate` `versions` `restore` `delete` | [Commands](cli/commands.md#secrets) | | Moving in bulk | `import` `pull` `export` | [Commands](cli/commands.md#moving-secrets-in-bulk) | | **Running your app** | `run` | [Commands](cli/commands.md#run) | | Administration | `audit` `members` `tokens` | [Commands](cli/commands.md#administration) | | Housekeeping | `cache clear` `completion` `doctor` `upgrade` `version` `help` | [Commands](cli/commands.md#housekeeping) | ## How the CLI knows what you mean Every command that touches a secret needs an *(organisation, project, environment)* triple. It is resolved in this order: 1. `--project` and `--environment` flags 2. `.xecret.yaml`, found by walking up from the current directory 3. Under `XECRET_TOKEN`: the service token's own pinned scope 4. Otherwise: an error telling you to run `xecret init` The organisation always comes from the credential — a login is pinned to one organisation, a service token to one organisation, project *and* environment. Naming a scope outside a service token's pin is refused by the **server**, not merely by the client. The full rules, plus the `.xecret.yaml` format and every environment variable: [configuration and scope](cli/configuration.md). ## Output conventions **stdout is results; stderr is commentary.** This is why ```bash xecret projects --json | jq '.[].slug' ``` works: warnings, hints, prompts and progress never share the pipe with the answer. - `--json` on listing commands emits one machine-readable document. - Colour is disabled when stdout is not a terminal, and by `NO_COLOR`. - Prompts are written to stderr, so a piped command still prompts you visibly. ## Exit codes | Code | Meaning | |---|---| | `0` | Success | | `1` | The CLI itself failed — bad flags, no credential, a rejected request | | *child's code* | `xecret run` exits with whatever your command exited with, untouched | That last row is why `xecret run -- npm test` is safe to use in CI: a failing test suite fails the job, exactly as it would without xecret in front of it. ## Errors tell you what to do next An API failure carries a hint line: ```text xecret: not signed in Run 'xecret login' to authenticate this device. ``` Under `XECRET_TOKEN` the hints change, because "run login" would be nonsense in a CI job with no browser: ```text xecret: request rejected (401) XECRET_TOKEN was not accepted. Check the value, its expiry, and any IP allowlist. ``` ## Where next - [Command reference](cli/commands.md) — every command, every flag. - [Configuration and scope](cli/configuration.md) — `.xecret.yaml`, environment variables, where credentials are stored. - [The offline cache](cli/offline-cache.md) — what happens when the network does not. - [Troubleshooting](troubleshooting.md) — when something is not working. --- # CLI command reference Source: https://xecret-staging.playxoft.com/docs/cli/commands Summary: Every xecret command and flag — login, init, projects, secrets, versions, import, pull, export, run, audit, tokens and cache — with examples and the exact behaviour of each. Updated: 2026-10-20 Every command the `xecret` binary accepts. Run `xecret help` for the same list in your terminal, or `xecret --help` for one command's flags. Two flags recur on every command that reads secrets: ```text --project SLUG override .xecret.yaml --environment SLUG override .xecret.yaml ``` ## Authentication ### `xecret login` ```bash xecret login [--api-url URL] [--name DEVICE] ``` Authenticates this machine through your browser. The CLI opens a consent page, listens on `127.0.0.1` on a random port for the one-time code that page redirects back, and exchanges that code — together with a PKCE verifier only this process knows — for a credential it stores in your OS keychain. It never talks to the identity provider directly; the consent screen and the token exchange are both xecret's own. If the browser is not signed in to xecret yet, it signs in first and comes back to the consent screen. | Flag | Effect | |---|---| | `--api-url URL` | Which deployment to sign in to. Stored with the credential, so you pass it once. | | `--name DEVICE` | The device name shown on the consent screen and in the dashboard. Defaults to your hostname. | ```bash xecret login --name "work laptop" xecret login --api-url https://secrets.your-company.com ``` The device name is what you will look for later when revoking access from *Tokens → Your devices*, so make it something you will recognise. Logging in again replaces the credential this machine held, and the CLI revokes the old one on the server once the new one is saved — so re-running `xecret login` does not leave a stale device behind. If that revocation fails (the old deployment is unreachable, say), the login still succeeds and says so; revoke the old device from *Tokens → Your devices*. Each plan limits how many devices one person may have signed in to an organisation (two on Free); at the limit, the consent screen says so and the way forward is to revoke one you no longer use. ### `xecret logout` ```bash xecret logout ``` Revokes this device's credential **server-side**, clears the keychain entry, and wipes the encrypted offline cache. Refused while `XECRET_TOKEN` is set: a service token was not minted by this machine and is not this machine's to sign out. Revoke one with [`xecret tokens revoke`](#xecret-tokens) or in the dashboard. ### `xecret whoami` ```bash xecret whoami [--json] ``` Asks the server who you are, rather than reading the stored credential. That distinction matters: a credential revoked from the dashboard reads as signed out here, instead of confidently reporting a stale identity. ```text Signed in as ada@example.com Organisation acme This device work laptop Server https://xecret.playxoft.com ``` Under `XECRET_TOKEN` it answers with the token's pinned scope instead of a person: ```text Credential service token "ci-build" (read) Organisation acme Project checkout-api Environment production Server https://xecret.playxoft.com ``` ### `xecret orgs` ```bash xecret orgs [list] [--json] xecret orgs use SLUG ``` Your credential works in every organisation you are a member of. `use` chooses which one the other commands address: ```text SLUG NAME ROLE acme Acme Ltd owner current side Side Project developer ``` `use` is a local change — one field stored beside the token. It makes no network call beyond checking that you are actually a member, revokes nothing, and switching back is another `use`. Refused under `XECRET_TOKEN`, which is pinned to one organisation server-side. ## Project setup ### `xecret init` ```bash xecret init [--project SLUG] [--environment SLUG] [--force] ``` Interactive picker that writes `.xecret.yaml` in the current directory — two slugs, never secrets, safe to commit. | Flag | Effect | |---|---| | `--project SLUG` | Skip the project prompt | | `--environment SLUG` | Skip the environment prompt | | `--force` | Overwrite an existing `.xecret.yaml` | ```bash xecret init # prompts for both xecret init --project checkout-api --environment development ``` Refused under `XECRET_TOKEN`: a CI job needs no config file, because the token's own scope already answers the question. ### `xecret projects` ```bash xecret projects [list] [--json] xecret projects delete SLUG [--yes] ``` Lists the projects you can see in your organisation, and removes one. **There is no `create` here — projects are created in the dashboard.** A project arrives with its default environments — development, staging and production — and each is end-to-end encrypted under a key generated in the creator's browser and sealed to them. This CLI opens grants and encrypts secrets; it cannot produce that key material, so there is nothing it could send. The slug you choose there is permanent: it appears in every URL, in `.xecret.yaml` and in the CI configuration of everyone who consumes the project, so renaming it would break every consumer that is not redeployed at the same instant. Deleting is soft, and asks you to type the slug back unless you pass `--yes`. ### `xecret environments` ```bash xecret environments [list] [--project SLUG] [--json] xecret environments delete SLUG [--yes] [--project SLUG] ``` Lists and removes the environments of one project. Without `--project`, uses `.xecret.yaml`. **There is no `create` here either, for the same reason.** An environment is inseparable from its encryption key — one without a key could not hold a secret — and that key is generated in a browser and sealed to its creator. Create the environment in the dashboard, then fill it from here: ```bash # a throwaway environment for one pull request, created in the dashboard as pr-412 xecret import .env.example --environment pr-412 --strategy overwrite xecret environments delete pr-412 --yes ``` Whether an environment is production is settled in the dashboard too. It narrows who can read the environment and makes deleting it ask for the slug, and reclassifying one that already holds production secrets is a different act from labelling an empty one. ## Secrets ### `xecret secrets list` ```bash xecret secrets list [--project P] [--environment E] [--json] ``` Names, value types, current version numbers, when each was last updated and by whom. **No values** — this endpoint never decrypts anything. ### `xecret secrets get` ```bash xecret secrets get NAME [--version N] [--plain] [--json] ``` Masked by default: ```text Name DATABASE_URL Type url Version v4 Updated 2026-08-14 09:12 Note primary read-write connection ``` `--plain` prints the decrypted value to stdout and nothing else, so it composes: ```bash psql "$(xecret secrets get DATABASE_URL --plain)" ``` Every `--plain` read is audited server-side as `secret.revealed`. An audit row therefore always means a plaintext actually left the server — which is what makes the log worth reading during an incident. `--version N` reveals one earlier value instead of the current one, and requires `--plain`, because the value is the only thing it adds over [`secrets versions`](#xecret-secrets-versions): ```bash xecret secrets get STRIPE_SECRET_KEY --version 3 --plain ``` A warning goes to stderr when the version is not the current one. It is worth reading: a rotated secret is usually still live at whoever issued it, so an old value is a working credential, not an archive entry. ### `xecret secrets set` ```bash xecret secrets set NAME [--type TYPE] [--note TEXT] [--from-file PATH] [--generate[=BYTES]] [--confirm-link] ``` Reads the value from stdin, or prompts for it with the input hidden. **Never from a command-line argument**, where it would land in your shell history and in `ps` output for every user on the machine. ```bash # interactive — the prompt hides your typing xecret secrets set STRIPE_SECRET_KEY # from a pipe, for scripts openssl rand -hex 32 | xecret secrets set SESSION_SECRET # from a file, byte for byte xecret secrets set GOOGLE_SERVICE_ACCOUNT --from-file key.json # have one generated — 32 bytes of entropy, never printed xecret secrets set SESSION_SECRET --generate # declare a type and a note xecret secrets set PORT --type int --note "the container listens here" ``` | Flag | Effect | |---|---| | `--type TYPE` | Declares the value's shape: `string` `boolean` `int` `decimal` `email` `url` `date` `datetime` `json` `yaml` `xml` `ulid` `uuidv4` `uuidv7`, or `template` for a value built from other secrets. Future writes that do not match are refused by the server. | | `--note TEXT` | A note shown beside the secret in the dashboard. | | `--from-file PATH` | Take the file's bytes as the value, verbatim — trailing newline included. A pipe's final newline is the shell's framing and is stripped; a file's is part of the file, and a PEM key ends in one. Limited to 64 KB, the server's ceiling on one secret. | | `--generate[=BYTES]` | Mint a random value with the OS entropy source and write it without ever printing it. 32 bytes by default, rendered as 43 url-safe characters. Use `--generate=48` — with the `=` — to choose a length. | | `--confirm-link` | Confirm a production template that newly reads from an environment that is not production. Without it such a write is refused, naming who could then change what production reads — see [references](../features/references.md#production-reading-from-elsewhere). | Writing the same value again is a no-op rather than a new version. A **template** (`--type template`, or a secret already declared one) refers to other secrets — `${DB_HOST}`, `${staging.DB_HOST}`, `${billing.prod.API_KEY}` — and is resolved when it is read. The CLI parses the value before encrypting it, refuses a malformed reference locally, and declares the references it found so the server can check them for loops before storing anything. See [References and inheritance](../features/references.md). If your organisation has a [secret policy](../features/policies.md#secret-naming-and-strength), the value is checked against its entropy and forbidden-word rules before it is encrypted, and refused with the rule it broke. The value itself is never shown. ### `xecret secrets annotate` ```bash xecret secrets annotate NAME [--note TEXT] [--type TYPE] [--rename NEW] ``` Changes what is *said about* a secret without touching what it holds, and **appends no version**. Declaring `PORT` an integer is not a rotation, and neither is a rename, so neither may bump the number that answers "when did this credential last actually change?". ```bash xecret secrets annotate PORT --type int xecret secrets annotate DATABASE_URL --note "primary read-write connection" xecret secrets annotate DATABASE_URL --note "" # clears the note xecret secrets annotate API_KEY --rename STRIPE_API_KEY ``` The version history follows the secret through a rename. Everything that reads the old name stops finding it at once, so update your code and CI in the same change. ### `xecret secrets versions` ```bash xecret secrets versions NAME [--json] ``` The history of one secret — **metadata only**. ```text VERSION WRITTEN BY v4 current 2026-08-17 09:12 user v3 2026-06-02 14:40 service token v2 2026-04-11 08:03 user ``` No values, and that is the point rather than a limitation. A rotated secret usually keeps working at the provider that issued it until somebody disables it there, so a listing that returned values would hand out a page of live credentials under an interface people reason about as an archive. Reading the past is one version at a time, through `secrets get --version N --plain` or `secrets restore` — and each of those is audited. ### `xecret secrets restore` ```bash xecret secrets restore NAME --version N ``` Re-appends an earlier value as the new current version. ```bash xecret secrets versions DATABASE_URL xecret secrets restore DATABASE_URL --version 3 ``` ```text ✓ Restored DATABASE_URL from v3 as v5 in checkout-api/production. History was not rewritten — v3 is still there. ``` The old row stays exactly where it is: the history stays able to say "this value was current between Tuesday and Friday, then again from Monday". The value is decrypted and re-encrypted rather than copied, because the encryption is bound to the version number — bytes produced for v3 and stored as v5 would fail to decrypt for the rest of their life. ### `xecret secrets delete` ```bash xecret secrets delete NAME [--yes] ``` Soft-deletes a secret. Without `--yes` you are asked to type the secret's name to confirm — a `y/n` prompt is too easy to answer by reflex for something that takes a value out of a running system. Always refused for service tokens: CI rotates a value by writing a new one, and destroying history is a human's decision. ## Moving secrets in bulk ### `xecret import` ```bash xecret import FILE [--format dotenv|json|yaml|shell] [--strategy skip|overwrite|rename] [--dry-run] [--json] ``` Sends a file of secrets to one environment. The format is auto-detected; `--format` overrides that when detection guesses wrong. | `--strategy` | On a name that already exists | |---|---| | `skip` *(default)* | Leave the existing value alone | | `overwrite` | Append a new version with the imported value | | `rename` | Import under a suffixed name, keeping both | ```bash xecret import .env --dry-run # see the plan, write nothing xecret import .env --strategy overwrite ``` `--dry-run` prints the exact plan the real import would execute, produced by the same planning code the real import runs — so the preview cannot disagree with the outcome. Always dry-run first against production. Files are limited to 1 MB. ### `xecret pull` ```bash xecret pull [--format env|json|yaml|shell|docker] [-o FILE] ``` Prints every current secret in the chosen format. ```bash xecret pull --format json | jq 'keys' xecret pull -o .env.production # creates the file 0600 ``` > **Warning** — `pull` writes plaintext secrets somewhere they persist. That is > a downgrade from `xecret run`, and the command says so on stderr every time. > `-o` at least creates the file with `0600` permissions. Use it for legacy > pipelines that cannot be changed, and use `run` everywhere else. ### `xecret export` ```bash xecret export [--format env|json|yaml|shell|docker] [-o FILE] [--force] ``` The same data as `pull`, written to a file. Without `-o` the name is derived from the format — `.env`, `secrets.json`, `secrets.yaml`, `secrets.sh`, `docker.env` — and an existing file is never overwritten without `--force`. The file is mode `0600` whether it was created or overwritten. A `--force` over a `.env` that some earlier `xecret pull > .env` left at `0644` narrows it before anything is written to it, rather than filling a world-readable file. ```bash xecret export # → .env, mode 0600 xecret export --format json -o config/secrets.json ``` It is a separate command from `pull` rather than a flag on it because the two are separate endpoints: the request path is what distinguishes "a build read its configuration" from "somebody took a copy" in the audit log. Both are recorded as `secret.read` with a count. > **Warning** — Exporting is a deliberate downgrade in security posture, and the > command exists anyway. The file is not encrypted, it outlives the session that > produced it, backup and sync tools will copy it, and no access grant can be > revoked after the fact, because the copy is no longer ours. It exists because > the alternative is worse: a team that cannot export pastes values into chat > one at a time, which is less safe and completely unaudited. Add the file to > `.gitignore` and delete it when you are done. Neither `pull` nor `export` includes your [personal overrides](../features/overrides.md); each says on stderr when you have some in the environment. Both refuse outright when the environment's policy [disables plaintext export](../features/policies.md#disabling-plaintext-export), naming the policy and who can change it. `xecret run` is never affected. ### `xecret vault open-archive` ```bash xecret vault open-archive FILE [--recovery-code] [--passphrase-stdin] [--format env|json|yaml|shell|docker] [--environment ORG/PROJECT/ENV] [--out-dir DIR] [--force] [--list] ``` Opens the archive xecret emails when a vault is reset — the `.zip`, or the `archive.json` inside it — with the **old** vault's master passphrase or one of its recovery codes, and prints every secret, grouped by environment. It works fully offline: no account, no login, no network, no configuration, no keychain. The passphrase (or, with `--recovery-code`, the code) is asked for without echo; `--passphrase-stdin` reads it from a pipe instead, never from a flag. `--out-dir` writes one file per environment, mode `0600`, instead of printing. `--list` shows what the archive holds without asking for anything. A value written under a key the account never held is reported, not printed; a damaged value is reported and makes the command exit non-zero. Everything that opened is still printed. See [Resetting your vault](/docs/security/vault-archive). ## The golden path {#run} ### `xecret run` ```bash xecret run [--project P] [--environment E] [--offline] [--no-cache] -- COMMAND [ARGS…] ``` Fetches the environment, injects it into the child process, forwards signals, and exits with the child's exit code. Secrets never touch disk, argv or stdout. ```bash xecret run -- npm run dev xecret run --environment staging -- ./deploy.sh xecret run -- go test ./... ``` Everything after `--` is your command. The `--` is required: without it the CLI cannot tell your flags from its own. | Flag | Effect | |---|---| | `--offline` | Use the encrypted cache without calling the API. Refused under `XECRET_TOKEN`, which never writes a cache. | | `--no-cache` | Neither read nor refresh the cache. | The API is authoritative; the cache answers only when the API *cannot* — a network failure or a 5xx. A 401, 403 or 404 is a *decision* (a revocation, above all), and decisions are never softened by a local file. Full behaviour: [the offline cache](offline-cache.md). An environment that inherits from another, or holds templates, is resolved before the process starts — every inherited value and every reference, each opened under its own environment's key. Logged in as yourself (never under `XECRET_TOKEN`), `run` then applies your personal overrides on top and names them on stderr; offline, it says it did not. ### `xecret overrides` ```bash xecret overrides list [--json] xecret overrides set NAME [-] [--from-file PATH] xecret overrides unset NAME ``` Your own value for one secret in one environment, used by `xecret run` on your machine in place of the shared one — a local database instead of staging's — without anybody else's environment changing. Nobody else can read it, the server included, and nobody else can make one in its place: it is encrypted under a key only your own vault derives. The value is read from stdin or a hidden prompt, never from an argument. ```bash printf '%s' "postgres://localhost/app" | xecret overrides set DATABASE_URL - xecret run -- npm run dev # applied 1 personal override(s): DATABASE_URL xecret overrides unset DATABASE_URL ``` Refused under `XECRET_TOKEN`: a CI run always gets the shared values. An override set before a vault reset no longer opens, and `run` stops rather than silently using the shared value, naming the `unset` that removes it. Setting one needs the Pro plan. See [Personal overrides](../features/overrides.md). ## Administration ### `xecret audit` ```bash xecret audit [--action ACTION] [--project P] [--environment E] [--outcome success|denied|error] [--since 24h|7d|TIMESTAMP] [--until 1h|7d|TIMESTAMP] [--limit N] [--json] ``` Reads the organisation's audit log — who did what, when, and whether it worked. `--since` and `--until` take the same spellings: a duration counting back from now (`24h`, `7d`), or an RFC 3339 timestamp, normalised to UTC before it is sent. A window that cannot contain anything — a negative duration, a `--since` after `--until`, a `--since` in the future — is refused here rather than sent. The server collapses a backwards range to a single instant, so each of those would otherwise come back as "No matching events", which reads exactly like "nothing happened". ```bash xecret audit --action secret.revealed --since 7d xecret audit --outcome denied --limit 100 --json | jq '.events[].actorLabel' ``` ```text WHEN ACTION OUTCOME ACTOR SUBJECT 2026-08-17 11:00 secret.revealed success ada@example.com checkout-api/production/API_KEY 2026-08-17 10:30 secret.read success ci-build checkout-api/production ``` Owners and admins hold `audit.read`; developers deliberately do not, because the log spans projects a developer cannot see and records every denial anyone ever received. A 403 here is the policy working. The window actually scanned is printed after the table. The server clamps any range to ninety days, and a query for "the last year" that quietly answered for three months would be worse than useless during an incident. `--limit` is honoured exactly, and the command says so when more events matched than it read. `--since` takes a duration (`24h`, `7d`) or an RFC 3339 timestamp. ### `xecret members` ```bash xecret members [--json] ``` Who is in the organisation, their roles, and the seat count. Read-only: inviting, suspending and changing a role require a browser session server-side, so there is nothing here that could do them. ### `xecret tokens` ```bash xecret tokens list [--kind cli|service] [--json] xecret tokens revoke ID --kind cli|service [--yes] ``` The credentials that can reach your organisation. - **`cli`** tokens are devices — what `xecret login` writes. The listing shows your own devices only, revoked ones included, because a CLI token acts as its user and confers nothing of its own. Your own device: always revocable. Someone else's: needs the revoke permission. - **`service`** tokens are the CI credentials behind `XECRET_TOKEN`. They belong to the organisation rather than to a person, so revoking one takes the same authority that could have minted it. ```bash xecret tokens list --kind service xecret tokens revoke 7f3c… --kind service --yes ``` Revocation is immediate: the next request carrying that token fails authentication. It is idempotent, so revoking a dead token succeeds without writing a second audit record. **Creating** a service token is deliberately not a CLI command. The server requires a browser session for it — a token that could mint another token would turn one leaked credential into a permanent foothold, so the chain has to start with a person. Create them in the dashboard under *Settings → Tokens*. ## Housekeeping ### `xecret cache clear` ```bash xecret cache clear ``` Removes every cached environment from this machine. `xecret logout` does this for you. ### `xecret completion` ```bash xecret completion bash | zsh | fish ``` Prints a completion script for commands, subcommands and the common flags. ```bash # bash — this session, or append to ~/.bashrc source <(xecret completion bash) # zsh — needs a directory on $fpath xecret completion zsh > "${fpath[1]}/_xecret" # fish xecret completion fish > ~/.config/fish/completions/xecret.fish ``` Secret names are deliberately not completed: that would mean an API call — and a listing request against a server that rate-limits and audits — on every press of the Tab key. ### `xecret doctor` ```bash xecret doctor [--json] ``` Checks this machine's setup and says what is wrong: which credential store is in use, whether you are signed in, which deployment the commands resolve to and *why*, whether the server is reachable, whether the credential is still accepted, which `.xecret.yaml` applies, and what is in the offline cache. ```text xecret 1.2.0 (commit a1b2c3d, built 2026-08-17, darwin/arm64, go1.25.5) ✓ credential store: OS keychain ✓ signed in as ada@example.com, organisation acme server: https://xecret.playxoft.com (stored with the credential at login) ✓ server reachable — xecret 0.1.0 (commit a1b2c3d) ✓ credential accepted by https://xecret.playxoft.com ✓ /work/checkout/.xecret.yaml → checkout-api/development ✓ offline cache: 2 environment(s) in ~/.xecret/cache ``` It prints no credential. The keyring check writes and deletes a probe value of its own rather than reading yours. Paste the output into a bug report. `doctor` exits non-zero when a check fails, so `xecret doctor || exit 1` works as a start-up guard in a container. Warnings — no `.xecret.yaml` in this directory, an unreadable cache — are not failures. With `--json` every verdict comes back in a `checks` array of `{name, status, ok, detail}`, alongside a top-level `ok`: ```bash xecret doctor --json | jq -r '.checks[] | select(.ok | not) | .detail' ``` ### `xecret upgrade` ```bash xecret upgrade [--json] ``` Asks GitHub whether a newer release has been published, and prints the command to install it. Nothing checks in the background. A version check is a request describing which machine runs which build of a secret-management client, and making it a side effect of `xecret run` would ship that from inside every CI job. It happens when you ask. It also does not replace the binary. Every published archive is checksummed and signed, and the installer verifies the checksum before unpacking; a secret manager that silently overwrites its own executable is exactly the supply-chain shape nobody should accept. ### The upgrade notice Commands that talk to your deployment may end with three lines on stderr: ```text ↑ xecret 0.2.0 is available (you have 0.1.2) Reads end-to-end encrypted environments - older builds return an empty value for them. Upgrade: xecret upgrade ``` This is not the background check the section above rules out, and it costs no request of any kind. Your deployment names the release it expects in a header on a response the CLI was already receiving; the CLI reads it and says so. Nobody is asked anything, and a server that sends no header produces no notice. Running your own deployment? Then you decide what your developers are told, by deciding what you deploy — which is usually more useful than pointing everyone at the newest tag on GitHub. The notice stays quiet when stderr is not a terminal, when `XECRET_TOKEN` is set (a CI job cannot upgrade itself, and the line would land in every build log for ever), when you have already been told about that release today, and when `XECRET_NO_UPGRADE_NOTICE` is set. A newer release than the one you last saw is raised straight away rather than waiting for tomorrow. What you were last told is remembered in `~/.xecret/notices.json` — a version and a date, nothing else. It sits outside the cache directory on purpose, so `xecret cache clear` and `xecret logout` are not read as "please start nagging me again". ### `xecret version` ```bash xecret version ``` Version, commit, build date, platform and Go version. Include this in any bug report. ### `xecret help` ```bash xecret help xecret secrets help ``` ## Quick reference ```bash # first time in a repository xecret login xecret init # daily xecret run -- npm run dev xecret overrides set DATABASE_URL # your local value, only for you # look at things xecret projects xecret environments xecret secrets list xecret secrets versions API_KEY # change things xecret secrets set API_KEY xecret secrets get API_KEY xecret secrets annotate API_KEY --note "issued by Stripe" xecret import .env --dry-run # when something went wrong xecret secrets restore API_KEY --version 3 xecret audit --action secret.revealed --since 24h xecret doctor # after a vault reset: open the emailed archive, offline xecret vault open-archive xecret-vault-archive-2026-10-12.zip # in CI (no login, no config file) XECRET_TOKEN=xst_… xecret run -- npm run build ``` ## Next - [Configuration and scope](configuration.md) — where each setting comes from. - [The offline cache](offline-cache.md) — behaviour when the network fails. - [Secrets in CI](../guides/ci.md) — service tokens end to end. --- # Configuration and scope Source: https://xecret-staging.playxoft.com/docs/cli/configuration Summary: How the CLI decides which project and environment you mean, the .xecret.yaml format, every environment variable, and where credentials are stored. Updated: 2026-08-16 The CLI takes configuration from four places. This page says exactly which one wins, and what each is for. ## Scope resolution Every secret-touching command needs an *(organisation, project, environment)* triple. It is resolved in this order, first answer winning: 1. **`--project` / `--environment` flags** — explicit beats everything. 2. **`.xecret.yaml`**, discovered by walking up from the working directory, the way `git` finds `.git`. So `xecret run` works from any subdirectory. 3. **The service token's pinned scope**, when `XECRET_TOKEN` is set. 4. **Nothing** — an error telling you to run `xecret init`. The **organisation** is never one of these. It always comes from the credential: a login is pinned to one organisation, a service token to one organisation, project *and* environment. > **Note** — Naming a scope outside a service token's pin is refused by the > server, not merely by the client. Passing `--environment production` to a job > whose token is pinned to `staging` fails with a 404, not a quiet success. The > client-side rules on this page are conveniences; the boundary is server-side. ## `.xecret.yaml` Written by `xecret init`, read by everything else. ```yaml project: checkout-api environment: development ``` Two slugs, and that is the entire file. The type that models it has exactly two fields, so nothing in the CLI can serialise anything else into it — which is what makes the file safe to commit. **Commit it.** It is what makes `git clone && xecret run -- npm run dev` work for a new teammate without a setup document. ### Per-developer overrides The file names the default environment, usually `development`. Anyone who needs a different one passes a flag rather than editing the committed file: ```bash xecret run --environment staging -- npm run dev ``` Or, if you always want a different default, set it for your shell session: ```bash export XECRET_ENVIRONMENT=staging # not read by the CLI — see below ``` That variable does *not* exist, deliberately. An environment variable that silently redirects which secrets a command loads is exactly the mechanism by which someone runs a migration against production believing it was staging. Be explicit with the flag. ## Environment variables | Variable | Effect | |---|---| | `XECRET_TOKEN` | Authenticate as a service token (`xst_…`). No login, no keychain, no offline cache. Overrides any stored login. | | `XECRET_API_URL` | Which deployment to talk to — used by `login` and by `XECRET_TOKEN` mode. A stored login remembers its own URL, so you do not need this after `xecret login --api-url …`. | | `XECRET_KEYRING=file` | Force the `0600` file fallback instead of the OS keyring. | | `XECRET_NO_UPGRADE_NOTICE` | Silence the [upgrade notice](commands.md#the-upgrade-notice). It is already silent under `XECRET_TOKEN` and whenever stderr is not a terminal. | | `NO_COLOR` | Disable colour. Output is also uncoloured whenever stdout is not a terminal. | ### `XECRET_TOKEN` in practice ```bash export XECRET_TOKEN=xst_live_… export XECRET_API_URL=https://secrets.your-company.com # self-hosted only xecret run -- npm run build ``` While `XECRET_TOKEN` is set: - `xecret login`, `logout` and `init` are refused — none of them make sense for a credential you did not create on this machine. - No offline cache is written or read. A CI runner is disposable; an encrypted cache file on it would be a liability with no benefit. - `whoami` reports the token's pinned scope rather than a person. ## Where credentials are stored The credential from `xecret login` goes into your operating system's keychain: | Platform | Store | |---|---| | macOS | Keychain | | Windows | Credential Manager | | Linux | Secret Service (GNOME Keyring, KWallet, …) | Where no keyring is available — a bare container, a headless server, a minimal window manager — the CLI falls back to a `0600` file under `~/.xecret/` and **tells you on stderr** that it has done so. It never falls back silently: you should know when your credential moved from a keychain to a file. Force the file store with `XECRET_KEYRING=file`. That is occasionally the right answer on a headless build machine where the keyring prompts for a password nobody can type. ## Files the CLI creates | Path | What it is | |---|---| | `.xecret.yaml` | Your project's scope. Commit it. | | `~/.xecret/cache/` | The encrypted offline cache, one file per (host, org, project, environment). | | `~/.xecret/` (fallback only) | The `0600` credential file, when no keyring exists. | `xecret cache clear` removes the cache; `xecret logout` removes both the cache and the credential. ## Pointing at a self-hosted deployment ```bash # once, interactively xecret login --api-url https://secrets.your-company.com # in CI, where there is no stored login export XECRET_API_URL=https://secrets.your-company.com export XECRET_TOKEN=xst_… ``` The compiled-in default is `https://xecret.playxoft.com`. See [self-hosting](../self-hosting.md). ## Next - [The offline cache](offline-cache.md) — what the cache does and does not do. - [Command reference](commands.md) — every flag in one page. - [Secrets in CI](../guides/ci.md) — the token-based setup end to end. --- # The offline cache Source: https://xecret-staging.playxoft.com/docs/cli/offline-cache Summary: How xecret keeps working when the network does not — what the encrypted local cache stores, exactly when it answers, and why a revoked credential is never softened by it. Updated: 2026-08-16 A secret manager that stops your `npm run dev` when its API has a bad day has made your work depend on our uptime. It should not. So `xecret run` keeps an encrypted copy of the last environment it fetched, and falls back to it. The interesting part is not that a cache exists. It is the rule for when it answers. ## The rule, stated once **The API is authoritative. The cache answers only when the API *cannot*, never when it *will not*.** | What happened | What `xecret run` does | |---|---| | The request succeeds | Uses the response, and refreshes the cache | | DNS fails, connection refused, timeout | Falls back to the cache, loudly | | The server answers 5xx | Falls back to the cache, loudly | | The server answers **401** | Fails. Your credential was rejected. | | The server answers **403** | Fails. You are not allowed. | | The server answers **404** | Fails. It does not exist, or is not yours. | A 401, 403 or 404 is a *decision*, and the most important decision in that list is **revocation**. When an admin revokes a laptop's access, that must take effect immediately — not "immediately unless the laptop turns off its Wi-Fi". Softening a rejection with a local file would make the revoke button a suggestion. A network failure is not a decision. It carries no information about whether you are still allowed, so falling back is safe. ## The fallback is loud Every time the cache answers, the CLI writes to **stderr**: ```text warning: xecret API unreachable — using cached secrets cached 2026-08-15 17:41 (18 hours ago), 24 secrets ``` Stderr, not stdout, so it never contaminates a pipe. Loud, because silently serving day-old configuration is how a rotated credential appears to still work. ## What is stored, and how The cache lives in `~/.xecret/cache/`, with one file per **(host, organisation, project, environment)**. - Encrypted with **AES-256-GCM**. - The key lives in your OS keychain, not beside the file. - The additional authenticated data binds each file to its exact scope. That last point is the one worth understanding. The scope is cryptographically bound into the ciphertext, so a cache file cannot be renamed from `staging` to `production` and decrypted as production — the authentication tag fails and the CLI treats the file as absent. Local ciphertext gets the same treatment as server-side ciphertext. Copying a cache file to another machine gets you nothing either: the key is in the first machine's keychain. ## Controlling it ```bash xecret run --offline -- npm run dev # do not call the API at all xecret run --no-cache -- npm run dev # neither read nor refresh the cache xecret cache clear # delete every cached environment ``` `--offline` is for working on a plane, or for proving to yourself that the fallback works before you rely on it. It is refused under `XECRET_TOKEN`, because a service token never writes a cache in the first place. `--no-cache` is the right flag for a shared or untrusted machine: nothing is written to disk, and an outage simply fails. ## Why CI has no cache Under `XECRET_TOKEN` no cache is written and none is read. A CI runner is disposable and rebuilt from nothing on every job, so a cache would almost never hit. What it would reliably do is leave an encrypted blob of production configuration on a machine somebody else's job runs on next. The benefit is near zero and the liability is real, so the feature is simply absent there rather than configurable. ## When the cache does not exist The first `xecret run` in a new environment has nothing cached. If that first run is also the moment the API is down, the command fails — there is nothing to fall back to. This is worth knowing before you rely on offline mode on a fresh machine: run once while online. ## Next - [Command reference](commands.md#run) — every `run` flag. - [Troubleshooting](../troubleshooting.md) — what each failure message means. - [Trust model](../security/trust-model.md) — the server-side half of the same story. --- # xecret with Next.js Source: https://xecret-staging.playxoft.com/docs/guides/nextjs Summary: Replace .env.local with xecret in a Next.js app — local development, NEXT_PUBLIC build-time variables, multiple environments, and deployment. Updated: 2026-08-16 Next.js reads configuration from `process.env`, which is exactly what `xecret run` fills in. Your code does not change. ## Local development ```bash xecret init # once per repository; commit .xecret.yaml xecret run -- next dev ``` Delete `.env.local`, and make sure `.env*` is in `.gitignore`. Everything the server side reads through `process.env` is already there. Two Next.js-specific things are worth knowing. ### `NEXT_PUBLIC_` variables are inlined at build time Anything prefixed `NEXT_PUBLIC_` is substituted into the JavaScript bundle during `next build`. That means: - It must be present in the environment of the **build**, so CI runs `xecret run -- next build`, not just `xecret run -- next start`. - It is **not a secret** once built. It ships to every browser that loads your site. Storing it in xecret keeps it managed and versioned; it does not make it private. Keep real credentials out of that prefix regardless of where they are stored. ### `next dev` does not re-read secrets on restart The environment is injected when `xecret run` starts the process. Hot reload does not re-fetch. After changing a secret, stop the dev server and run the command again — the same moment you would have edited `.env.local`. ## Server-only secrets Next.js server components, route handlers and server actions can read anything in `process.env`. The rule to hold onto is Next's own: a variable without the `NEXT_PUBLIC_` prefix is never sent to the browser, so a database URL read in a server component stays on the server. ```ts // app/api/health/route.ts — runs on the server, never in a bundle export async function GET() { const url = process.env.DATABASE_URL; // … } ``` ## Multiple environments `.xecret.yaml` names the default, usually `development`. Point a single command somewhere else without editing it: ```bash xecret run --environment staging -- next start xecret run --environment production -- next build ``` Production is deny-by-default for the `developer` role: reaching it takes an explicit grant from somebody who manages members, not a flag. See [teams, roles and access](teams.md). ## CI and deployment ```yaml # GitHub Actions - uses: playxoft/xecret@v1 - run: xecret run -- next build env: XECRET_TOKEN: ${{ secrets.XECRET_TOKEN }} ``` Because `NEXT_PUBLIC_` values are baked in at build time, the build step is the one that must run under `xecret run`. Full CI recipes: [secrets in CI](ci.md). ### Platforms that inject the environment themselves Vercel, Cloudflare Pages and similar platforms set environment variables from their own dashboards at deploy time. Two options: 1. **Keep xecret as the source of truth and sync at deploy.** Pipe `xecret pull --format env` into the platform's own CLI as a deploy step. Nothing is committed, and there is still one place a value is edited. 2. **Build in CI under `xecret run`, deploy the artefact.** Preferable where the platform accepts a prebuilt output, because nothing has to be synchronised at all. ```bash # option 1, sketched — never commit the output of this xecret pull --format env | while IFS='=' read -r key value; do your-platform-cli env set "$key" "$value" --environment production done ``` ## Next.js on Cloudflare Workers If you deploy through `@opennextjs/cloudflare` — as xecret itself does — note that Worker bindings and `process.env` are two different things at runtime. Use xecret for build-time configuration and for anything your Node-compatible code reads from `process.env`; use Wrangler's own `vars` and secrets bindings for values the Worker runtime resolves. ## Next - [Node.js](nodejs.md) — the same patterns without the framework. - [Secrets in CI](ci.md) — service tokens end to end. - [Command reference](../cli/commands.md) — every flag. --- # xecret with Node.js Source: https://xecret-staging.playxoft.com/docs/guides/nodejs Summary: Use xecret in any Node project — npm scripts, dotenv removal, TypeScript, monorepos, tests, and reading configuration safely at startup. Updated: 2026-08-16 Node reads configuration from `process.env`. `xecret run` fills it. There is no SDK to install and no code to change. ## The one change to make ```bash xecret init # once per repository; commit .xecret.yaml xecret run -- node server.js ``` Then delete the `dotenv` call and the `.env` file: ```js // before import 'dotenv/config'; const url = process.env.DATABASE_URL; // after — the import is gone; the second line is unchanged const url = process.env.DATABASE_URL; ``` Keep `dotenv` as a dependency only if something else in your toolchain needs it. It is harmless when there is no `.env` for it to load, but leaving it in place is a second source of truth waiting to disagree with the first. ## npm scripts Put `xecret run` in the script, so nobody has to remember it: ```json { "scripts": { "dev": "xecret run -- node --watch server.js", "start": "node server.js", "test": "xecret run --environment development -- node --test", "migrate": "xecret run -- node scripts/migrate.js" } } ``` Note `start` has no `xecret run`. In production the environment is usually supplied by the platform — a container's env vars, a systemd unit — and the process should read whatever it is given. Wrapping `start` too would mean your production container needs network access to xecret at boot. > **Tip** — If you *do* want the container to fetch its own secrets at boot, > that is a legitimate pattern, and [Docker](docker.md) covers it, including > the trade-off you are accepting. ## Fail fast on missing configuration `xecret run` guarantees the variables that exist in the environment you chose are present. It cannot know your code needs one you never stored. Validate at startup, where the error is cheap: ```js const REQUIRED = ['DATABASE_URL', 'SESSION_SECRET', 'STRIPE_SECRET_KEY']; const missing = REQUIRED.filter((name) => !process.env[name]); if (missing.length > 0) { console.error(`Missing configuration: ${missing.join(', ')}`); console.error('Are you running under `xecret run`, and is the environment right?'); process.exit(1); } ``` Never log the values, only the names. A crash report that helpfully prints your configuration is a crash report that leaks it. ## TypeScript Nothing special is required, but typing the variables you depend on turns a runtime surprise into a compile-time one: ```ts // env.ts const required = (name: string): string => { const value = process.env[name]; if (!value) throw new Error(`Missing ${name}`); return value; }; export const config = { databaseUrl: required('DATABASE_URL'), sessionSecret: required('SESSION_SECRET'), port: Number(process.env.PORT ?? 3000), } as const; ``` ## Monorepos `.xecret.yaml` is found by walking up from the working directory, the way `git` finds `.git`. Two arrangements both work: - **One file at the repository root** — every package shares an environment. Simple, and right when the packages deploy together. - **One file per package** — each service gets its own project and its own secrets. Right when they deploy separately. ```text repo/ ├── .xecret.yaml project: platform, environment: development ├── apps/api/ inherits the root file └── apps/worker/ └── .xecret.yaml project: worker, environment: development ``` The nearest file wins, because the walk stops at the first one it finds. ## Tests Point tests at a dedicated environment so a test run can never touch real credentials: ```json { "scripts": { "test": "xecret run --environment test -- vitest run" } } ``` Create a `test` environment in the dashboard alongside `development`. Its values can be fakes — the point is that they are managed and shared rather than invented per machine. ## Reading a single secret in a script For a one-off script that needs exactly one value, `xecret run` is still the right answer. But `secrets get --plain` composes when you need the value as a string: ```bash psql "$(xecret secrets get DATABASE_URL --plain)" ``` Every `--plain` read is audited, which is the intended trade: convenient, and recorded. ## Next - [Next.js](nextjs.md) — build-time variables and `NEXT_PUBLIC_`. - [Docker](docker.md) — containers, without baking secrets into layers. - [Secrets in CI](ci.md) — service tokens. --- # xecret with React and Vite Source: https://xecret-staging.playxoft.com/docs/guides/react-vite Summary: Use xecret with a Vite front end — the VITE_ prefix, why browser configuration is never secret, and where the real credentials belong instead. Updated: 2026-08-16 Vite exposes variables prefixed `VITE_` to your application code through `import.meta.env`. `xecret run` supplies them to the build. ```bash xecret init xecret run -- vite dev xecret run -- vite build ``` Delete `.env`, `.env.local` and friends. ## The thing to understand before anything else **Nothing a browser can read is a secret.** Vite substitutes `VITE_`-prefixed variables into the JavaScript bundle at build time. That bundle is downloaded by every visitor. "View source" is all it takes to read every one of them. This is not a Vite flaw — it is what a front end *is*. But it changes what xecret is doing for you here: | | | |---|---| | What xecret **does** give you | One managed, versioned, audited place for build configuration, shared across the team and CI, with no `.env` files to keep in sync. | | What it **cannot** give you | Privacy for anything in that bundle. | So `VITE_API_URL`, `VITE_SENTRY_DSN` and `VITE_STRIPE_PUBLISHABLE_KEY` are fine to store and inject. `STRIPE_SECRET_KEY` is not — not because xecret would handle it badly, but because a browser would. Real credentials belong in the back end that your front end talks to. Give that service its own project or its own environment, and let it hold the keys. ## Local development ```json { "scripts": { "dev": "xecret run -- vite", "build": "xecret run -- vite build", "preview": "xecret run -- vite preview" } } ``` ```ts // src/config.ts export const apiUrl = import.meta.env.VITE_API_URL; ``` ## Build time is the moment that matters Because substitution happens during `vite build`, the build is what must run under `xecret run`: ```yaml - uses: playxoft/xecret@v1 - run: xecret run -- npm run build env: XECRET_TOKEN: ${{ secrets.XECRET_TOKEN }} ``` Serving the built files afterwards needs no xecret at all — the values are already inside them. Which also means: to change one, you must rebuild. ## Different environments, different builds ```bash xecret run --environment staging -- vite build xecret run --environment production -- vite build ``` Each produces a different bundle, because each inlined different values. Keep the two artefacts distinct in your deployment pipeline; a staging bundle deployed to production points at the staging API and will look like it works. ## A separate project for the front end If your front end and back end live in one repository but deploy separately, give them separate xecret projects: ```text repo/ ├── web/ │ └── .xecret.yaml project: acme-web, environment: development └── api/ └── .xecret.yaml project: acme-api, environment: development ``` Then a service token minted for the web build cannot read the API's credentials, because it is pinned to a project that does not contain them. That is the whole reason to split them. ## Next - [Node.js](nodejs.md) — for the back end holding the real secrets. - [Secrets in CI](ci.md) — build tokens, scoped to one project. - [Core concepts](../concepts.md) — projects, environments and grants. --- # xecret with Go Source: https://xecret-staging.playxoft.com/docs/guides/go Summary: Use xecret in a Go project — go run and go test under injected secrets, reading configuration at startup, and building static binaries in CI. Updated: 2026-08-16 Go reads configuration with `os.Getenv`. `xecret run` supplies it. No library, no code change. ```bash xecret init xecret run -- go run ./cmd/server xecret run -- go test ./... ``` ## Reading configuration The Go convention is to read every variable once at startup and fail loudly rather than discovering a missing value on the first request at 3am: ```go package config import ( "fmt" "os" "strconv" ) type Config struct { DatabaseURL string SessionSecret string Port int } func Load() (*Config, error) { cfg := &Config{ DatabaseURL: os.Getenv("DATABASE_URL"), SessionSecret: os.Getenv("SESSION_SECRET"), Port: 8080, } // Names only. A configuration error must never print the value it // rejected — in this program the rejected value is a credential. for name, value := range map[string]string{ "DATABASE_URL": cfg.DatabaseURL, "SESSION_SECRET": cfg.SessionSecret, } { if value == "" { return nil, fmt.Errorf("missing %s (is this running under `xecret run`?)", name) } } if port := os.Getenv("PORT"); port != "" { parsed, err := strconv.Atoi(port) if err != nil { return nil, fmt.Errorf("PORT is not a number") } cfg.Port = parsed } return cfg, nil } ``` If `PORT` is a number, declare that in xecret too and the server will refuse a bad write in the first place: ```bash xecret secrets set PORT --type int ``` ## Tests `go test` runs each package's tests in a separate process, and each inherits the environment `xecret run` created — so one wrapper covers the whole run: ```bash xecret run --environment test -- go test ./... ``` Create a `test` environment beside `development` so a test run can never reach real credentials. Tests that need no configuration at all are better still: prefer passing a `Config` value into your constructors over reading `os.Getenv` deep inside them. ## Makefile ```makefile .PHONY: dev test build dev: xecret run -- go run ./cmd/server test: xecret run --environment test -- go test ./... -race build: CGO_ENABLED=0 go build -o dist/server ./cmd/server ``` `build` deliberately has no `xecret run`: compiling a Go binary needs no secrets. Only commands that *execute* your code do. ## CI ```yaml # GitHub Actions - uses: actions/setup-go@v5 with: { go-version: '1.25' } - uses: playxoft/xecret@v1 - run: xecret run -- go test ./... -race env: XECRET_TOKEN: ${{ secrets.XECRET_TOKEN }} ``` Point the token at your `test` environment, read-only. A test run has no business holding a write credential. Full recipes: [secrets in CI](ci.md). ## In production A compiled Go binary usually runs in a container or under systemd, taking its environment from the platform. Two options: - **The platform supplies the environment** — the usual arrangement. Sync it from xecret at deploy time. - **The container fetches its own** — `xecret run -- /app/server` as the entrypoint, with a service token in the container's environment. Simpler to keep in sync, at the cost of the process needing network access to xecret at start-up. [Docker](docker.md) covers both. ## Next - [Docker](docker.md) — shipping the binary. - [Secrets in CI](ci.md) — service tokens end to end. - [Node.js](nodejs.md) — the same patterns in another runtime. --- # xecret with Docker Source: https://xecret-staging.playxoft.com/docs/guides/docker Summary: Keep secrets out of image layers — runtime injection, BuildKit secret mounts, Compose, and the export format that exists only for docker run. Updated: 2026-08-16 One rule shapes everything on this page: > **Important** — A secret must never end up in an image layer. `ENV`, `ARG` > and `COPY .env` all bake the value into image history, where > `docker history` reads it back out of any copy of the image, forever. ## Runtime injection — the right default Do not put secrets in the image at all. Build an image that expects its configuration from the environment, and supply it when the container runs. ```dockerfile # Dockerfile — no secrets anywhere in it FROM node:22-slim WORKDIR /app COPY . . RUN npm ci && npm run build CMD ["node", "server.js"] ``` ```bash xecret run -- docker run --rm \ -e DATABASE_URL -e STRIPE_SECRET_KEY \ my-app ``` The mechanism is worth understanding. `xecret run` puts the values into *its own* process environment. `-e NAME` with **no `=value`** tells Docker to forward the variable of that name from the surrounding environment. The value therefore never appears on a command line, in your shell history, or in `ps` output. Writing `-e DATABASE_URL="$DATABASE_URL"` would undo all of that. ## When the container should fetch its own secrets For an orchestrator that cannot forward a long list of variables, put the CLI in the image and make it the entrypoint: ```dockerfile FROM ghcr.io/playxoft/xecret:1.2.0 AS xecret FROM node:22-slim COPY --from=xecret /usr/local/bin/xecret /usr/local/bin/xecret WORKDIR /app COPY . . RUN npm ci && npm run build ENTRYPOINT ["xecret", "run", "--"] CMD ["node", "server.js"] ``` ```bash docker run --rm -e XECRET_TOKEN=xst_… my-app ``` What you are trading: the container now needs network access to xecret at start-up, and one credential (`XECRET_TOKEN`) instead of many. That is often a good trade — one secret to manage in your orchestrator rather than twenty — but it does mean a xecret outage delays a container restart. There is no offline cache under a service token, deliberately; see [the offline cache](../cli/offline-cache.md). Keep the token scoped to exactly one project and environment, read-only. ## Build-time secrets Sometimes a build genuinely needs a credential — a private npm registry, a private Go module proxy. Use **BuildKit secret mounts**, the one mechanism that keeps the value out of layers and build history: ```dockerfile # syntax=docker/dockerfile:1 FROM node:22-slim WORKDIR /app COPY . . # The secret exists only for this RUN, as a tmpfs file. It is in no layer. RUN --mount=type=secret,id=npm_token \ NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci ``` Feed one value from xecret into the mount without it touching disk: ```bash XECRET_TOKEN=xst_… xecret secrets get NPM_TOKEN --plain \ | docker build --secret id=npm_token,src=/dev/stdin . ``` ## Docker Compose Compose reads `env_file` and `environment` from the host. Inject at the point you run Compose: ```yaml # compose.yaml services: api: build: . environment: - DATABASE_URL - STRIPE_SECRET_KEY ``` ```bash xecret run -- docker compose up ``` Again: names only, no `=value`. Compose forwards each from the environment `xecret run` created. For a stack where each service needs a different environment, run Compose under the widest one and scope inside your application, or split the services into separate projects with separate tokens. ## The `docker` export format `xecret pull --format docker` produces the shape `docker run --env-file` expects. It exists for **runtime** use: ```bash xecret pull --format docker -o /run/secrets.env # 0600 docker run --env-file /run/secrets.env my-app rm /run/secrets.env ``` Even here you are writing plaintext to disk, so prefer the `-e NAME` form above. Never feed this format to `docker build`. ## What not to do ```bash # ✗ bakes every secret into a layer, readable with `docker history` xecret pull --format docker > args.txt && docker build $(cat args.txt) . ``` ```dockerfile # ✗ the file is in the layer even if a later step deletes it COPY .env .env # ✗ ARG and ENV values are visible in image metadata ARG STRIPE_SECRET_KEY ENV STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY ``` Deleting a file in a later layer does not remove it from the image. Each layer is kept. ## Kubernetes and other orchestrators The same fork applies: - **Sync into the platform's own secret store** at deploy time, and let the platform mount them. Your workloads need no xecret access at all. - **Give each workload a service token** and use `xecret run` as the entrypoint, as above. The first is the conventional choice for production clusters; the second is simpler to keep in sync. Both are legitimate — pick one deliberately rather than ending up with half of each. ## Next - [Secrets in CI](ci.md) — building the image. - [Tokens](../api/tokens.md) — scoping the credential the container holds. - [The offline cache](../cli/offline-cache.md) — why CI and containers have none. --- # Secrets in CI/CD Source: https://xecret-staging.playxoft.com/docs/guides/ci Summary: Service tokens end to end — GitHub Actions, GitLab CI, CircleCI and generic runners — with scoping, rotation and the one anti-pattern to avoid. Updated: 2026-08-16 Every CI recipe follows the same three-line story: 1. **Mint a service token** in the dashboard, under *Tokens → New service token*. It is pinned to exactly one project and one environment, read-only by default, and shown exactly once. 2. **Expose it to the job** as the environment variable `XECRET_TOKEN`. 3. **Run your build through `xecret run`.** Secrets are injected into the child process's environment — never written to disk, never echoed to the log. There is nothing else to configure. The token knows its own scope, so no `.xecret.yaml`, no organisation flag and no login step are needed in CI. ## Minting the token In the dashboard: *Tokens → New service token*. | Field | What to choose | |---|---| | Name | What the token is for — `ci-build`, `deploy-staging`. It appears in the audit log against every action the token takes. | | Project + environment | Exactly the one the job needs. This is the pin; it cannot be widened later. | | Access level | `read` unless the job writes secrets. Most do not. | | Expiry | Set one. A credential with no expiry is a credential you will forget you issued. | | IP allowlist | If your runners have stable egress addresses, use it. | The token value is displayed once. Copy it straight into the CI provider's secret store and close the dialog — it cannot be retrieved again, only replaced. > **Note** — A service token can never delete a secret, and `read` tokens > cannot write. CI rotates a value by writing a new version; destroying history > is a human's decision. See [tokens](../api/tokens.md). ## GitHub Actions Store the token as a repository or environment secret named `XECRET_TOKEN` (*Settings → Secrets and variables → Actions*). ```yaml name: build on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # Installs the CLI and puts it on PATH. Pin a version for reproducible # builds: with: { version: v1.2.3 } - uses: playxoft/xecret@v1 # Secrets reach `npm run build`'s environment only — never the # workspace, the logs, or any later step. - run: xecret run -- npm run build env: XECRET_TOKEN: ${{ secrets.XECRET_TOKEN }} ``` The token is deliberately **not** an input to the action. Putting it in `env:` on the steps that use it means GitHub masks it and it exists only where it is needed, rather than spreading through the workflow context. ### Different environments per branch ```yaml - run: xecret run -- npm run deploy env: XECRET_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.XECRET_TOKEN_PRODUCTION || secrets.XECRET_TOKEN_STAGING }} ``` Two tokens, each pinned to its own environment, is safer than one token with a `--environment` flag: the pin is enforced by the server, and a mistake in the expression cannot reach production. ## GitLab CI Add `XECRET_TOKEN` under *Settings → CI/CD → Variables*. Mark it **Masked** — it is one line of high-entropy base64url, so masking works — and **Protected** if your plan offers it. ```yaml build: image: node:22 script: - curl -fsSL https://xecret.playxoft.com/install.sh | sh - xecret run -- npm run build ``` For a job that needs only the CLI itself — for example writing an env file for a later stage that cannot be changed: ```yaml export-legacy-env: image: name: ghcr.io/playxoft/xecret:latest entrypoint: [""] script: - /usr/local/bin/xecret pull --format env > .env artifacts: paths: [.env] expire_in: 30 min ``` Note the short `expire_in`. A build artefact containing plaintext secrets is a liability with a half-life; if you must produce one, make it expire. ## CircleCI Add `XECRET_TOKEN` under *Project Settings → Environment Variables*, or in a Context shared across projects. ```yaml version: 2.1 jobs: build: docker: - image: cimg/node:22.9 steps: - checkout - run: name: Install xecret command: curl -fsSL https://xecret.playxoft.com/install.sh | sh - run: name: Build with secrets injected command: xecret run -- npm run build workflows: build: jobs: - build ``` ## Any other runner The pattern is provider-independent: ```bash export XECRET_TOKEN="$CI_SECRET_XECRET_TOKEN" curl -fsSL https://xecret.playxoft.com/install.sh | sh xecret run -- make build ``` Self-hosted deployments add one variable: ```bash export XECRET_API_URL=https://secrets.your-company.com ``` ## What CI does differently Under `XECRET_TOKEN` the CLI behaves differently in three ways, all deliberate: - **No offline cache** is written or read. A runner is disposable, so a cache would rarely hit — while reliably leaving an encrypted blob of production configuration on a machine somebody else's job runs next. - **`login`, `logout` and `init` are refused.** None of them mean anything for a credential the machine did not create. - **Error hints change**, because "run `xecret login`" is useless advice in a job with no browser. ## Rotating a token 1. Mint a new token with the same name and scope. 2. Update the CI provider's secret. 3. Revoke the old one in the dashboard. Revocation is immediate: the lookup filters on "not revoked" in SQL, so an in-flight job using the old token fails on its next request. Do step 2 before step 3. ## The one anti-pattern to avoid ```bash # ✗ don't xecret pull --format env > .env ``` This writes plaintext secrets to the runner's disk, where they survive into caches, artefacts, and anything that archives the workspace. The command exists for legacy pipelines that genuinely cannot be changed, it warns on stderr every time, and `xecret run` makes it unnecessary everywhere else. If you must, write it somewhere ephemeral and delete it in the same script. ## Next - [Tokens](../api/tokens.md) — what each credential can do. - [Docker](docker.md) — containers and BuildKit secret mounts. - [Teams, roles and access](teams.md) — who can mint a token. --- # Importing and exporting secrets Source: https://xecret-staging.playxoft.com/docs/guides/import-export Summary: Move secrets in and out of xecret — .env, JSON, YAML and shell formats, conflict strategies, dry runs, and migrating from another secret manager. Updated: 2026-08-16 Getting your existing configuration in, and getting it out again if you ever want to leave. Both matter: a secret manager you cannot export from is a secret manager that has taken your configuration hostage. ## Importing from the dashboard Open a project, pick an environment, and drag your file into the import dialog. The file is parsed **in your browser**. A dry-run preview shows exactly what will be created, renamed or skipped, and only the resulting plan is sent — so a pasted blob of production credentials never becomes a request body. Supported formats, auto-detected: | Format | Looks like | |---|---| | `dotenv` | `KEY=value`, with or without `export `, quotes, and `#` comments | | `json` | A flat object of string values | | `yaml` | A flat mapping | | `shell` | A script of `export KEY=value` lines | Multiline PEM blocks, CRLF line endings and trailing comments all parse the way you would hope. ## Importing from the CLI ```bash xecret import .env --dry-run xecret import .env --strategy overwrite xecret import config.json --format json ``` | Flag | Effect | |---|---| | `--format` | Force a parser instead of detecting one | | `--strategy skip` | *(default)* leave existing names alone | | `--strategy overwrite` | append a new version for names that already exist | | `--strategy rename` | import under a suffixed name, keeping both | | `--dry-run` | print the plan and write nothing | | `--json` | machine-readable output | **Always dry-run first against a production environment.** The preview is produced by the same planning code the real import runs, so it cannot disagree with the outcome. Files are limited to 1 MB, and a single secret value to 64 KB. ### Names that need fixing Secret names must match your shell's rules: letters, digits and underscores, never starting with a digit. If your source file has `my-key` or `2fa-secret`, the import reports them rather than silently mangling them. Rename them in the source file and import again — a rename you did not choose is a rename you will be debugging at 3am. ## Exporting ```bash xecret pull --format env xecret pull --format json | jq 'keys' xecret pull --format yaml -o config.yaml # created 0600 ``` | Format | Output | |---|---| | `env` | `KEY=value` lines, quoted where needed | | `json` | one object | | `yaml` | one mapping | | `shell` | `export KEY=value` lines, for `source` | | `docker` | the shape `docker run --env-file` expects | The dashboard has the same thing under the environment's export dialog, as a file download. > **Warning** — Every one of these writes plaintext secrets somewhere they > persist. `pull` says so on stderr each time it runs. Use it to migrate, to > feed a pipeline that cannot be changed, and to prove to yourself you can > leave — not as a daily habit. [`xecret run`](../cli/commands.md#run) is the > daily habit. ## Migrating from another tool ### From `.env` files The whole job: ```bash xecret import .env --dry-run # look at the plan xecret import .env # do it rm .env # and mean it echo ".env*" >> .gitignore ``` Then replace the run command in your scripts — see [Node.js](nodejs.md), [Next.js](nextjs.md) or [Go](go.md). If different developers have drifted apart, collect one canonical file first: diff two colleagues' `.env` files and reconcile before importing. The import is a good moment to notice that half the team has a stale Stripe key. ### From another secret manager Most tools can export a flat `.env` or JSON. Export one environment at a time, import it into the matching xecret environment, and verify before deleting anything: ```bash other-tool export --env production > production.env xecret import production.env --environment production --dry-run xecret import production.env --environment production # verify: the names should match, one for one diff <(sort -t= -k1,1 production.env | cut -d= -f1) \ <(xecret pull --environment production --format env | cut -d= -f1 | sort) shred -u production.env # or rm, on a filesystem where that is enough ``` Do one environment at a time, starting with `development`. Migrating production first is how a migration becomes an incident. ### Keeping both for a transition period While you are moving over, it is reasonable to run both. Make xecret the source of truth immediately and sync outwards from it — never the reverse — so there is never a question about which copy is current: ```bash xecret pull --format env | other-tool import --env production ``` ## What is not exported - **Version history.** `pull` gives you the current value of each secret. Older versions stay in xecret; there is no bulk history export. - **Notes, value types, and grants.** These are metadata about how the environment is managed, not configuration your app reads. - **The audit log.** Available through [the API](../api/reference.md) for owners and admins. ## Next - [Command reference](../cli/commands.md) — `import` and `pull` in full. - [Core concepts](../concepts.md) — naming rules and value types. - [Secrets in CI](ci.md) — why `pull > .env` is the anti-pattern there. --- # Teams, roles and access Source: https://xecret-staging.playxoft.com/docs/guides/teams Summary: Invite people, choose roles, grant access per project and environment, and understand why production is denied by default to developers. Updated: 2026-08-16 Everything about who can see what. If you are working alone, you can skip this page until somebody joins. ## Inviting somebody *Members → Invite*. You need an email address and a role. The invitation email carries a single-use link. Accepting it requires being signed in as **that same address** — a forwarded email must not let a colleague join as somebody else. Only the hash of the invitation token is stored, so the link is shown to you exactly once, at creation. If mail is not configured on a self-hosted deployment, you get a shareable link instead of an email. You cannot invite somebody at a role above your own. ## The four roles Ordered `viewer` < `developer` < `admin` < `owner`. | Role | Can | Cannot | |---|---|---| | `owner` | Everything, including deleting the organisation | — | | `admin` | Manage members, tokens, projects, environments; read the audit log | Delete the organisation | | `developer` | Read and write secrets where granted | Manage members or tokens | | `viewer` | Read secrets where granted | Write anything | A role decides what *class* of action is available to you at all. It is org-wide and no grant can change it: an `admin` grant on one environment cannot turn a `viewer` into a writer, because the viewer's capability table says "cannot write" and grants do not edit that table. ## Access levels The second gate. Levels are ordered `none` < `read` < `write` < `admin`, and they apply to a specific project or environment. Where no grant mentions a resource, the role supplies a default: | Role | Non-production | Production | |---|---|---| | `owner` | admin | admin | | `admin` | admin | admin | | `developer` | write | **none** | | `viewer` | read | **none** | ### Why production is deny-by-default A developer who needs production must be granted it explicitly, by somebody who manages members, and that grant is written to the audit log with a name attached. The alternative — production behaving like every other environment until somebody remembers to lock it down — makes the safe state the one that requires work. Every organisation that has done it the other way has, at some point, discovered a contractor with production access nobody meant to give. `viewer` gets `none` on production for a related reason: a viewer with `read` there would hold strictly more production access than a developer, which no reviewer could justify. ## Grants A grant sets a level on a **whole project** or on a **single environment**. More specific wins: ```text developer role → write on non-production, none on production + project grant: checkout-api = read → read across checkout-api + environment grant: production = write → write on checkout-api/production ``` `none` always denies, whatever the role default would have said. That makes it the tool for carving one project out of an otherwise trusted role. Grants are managed on each member's own page: *Members → (person) → Access*. ### The effective-access preview The same page shows every project and environment with the member's **resolved** level and the rule that produced it: | Environment | Level | Because | |---|---|---| | checkout-api / development | write | role default | | checkout-api / production | write | environment grant | | billing / production | none | role default | | billing / staging | none | project grant | This is computed by the same function the enforcement path calls, so it cannot disagree with what actually happens. When somebody says "I can't see the staging secrets", this table is the first place to look. Anyone can see their own row. Seeing somebody else's requires the permission to manage members. ## Inviting with access already set The invitation form lets you tick the projects and environments the person should get. When you use it, acceptance becomes **deny-by-default**: every project that exists at that moment gets an explicit `none` unless you selected it, and the ones you selected get the invited role's normal level. A ticked production environment is the conscious act that grants production. Projects created *after* they accept fall back to the role default; narrowing that is the member page's job. ## Changing and removing people | Action | Requires | |---|---| | Change a role | Managing members, and the role hierarchy on **both** sides | | Suspend | Managing members. Keeps the membership, denies everything. | | Reinstate | Managing members | | Remove | Managing members. Grants are deleted with the membership. | "Both sides" means: to change somebody from `developer` to `admin`, you must be at least an `admin` yourself, to assign that role, *and* rank above the role they currently hold, to touch them at all. Without the second half, an admin could "demote" an owner — which is removing an owner's authority without holding it. You cannot change or remove yourself, and an organisation can never be left without an active owner. Both are enforced inside the database transaction, not by the form. Suspension is the right tool for "somebody is on leave" or "we are investigating". Removal is permanent, and their grants do not come back. ## Custom roles On the Enterprise plan, an organisation can define its own roles — job titles such as "Deployer" or "Contractor" — under *Organisation settings → Roles*, and put members on them from the member list. - **A custom role only takes away.** It names a built-in role to narrow, and a member holding it keeps only the actions you tick, of those that role already has. It can never give anybody more than the role it is based on, or more than their own built-in role. - **An optional ceiling** caps how far its holders reach, separately outside and inside production — "never above read in production", say. It caps explicit grants too, so a production grant written by mistake cannot get past it. - **Everybody still sees the member list.** Seeing who is in the organisation is always included. - **You cannot hand out more than you hold.** Defining a role needs an owner or admin who holds no custom role. Editing one, or moving somebody onto or off one, is refused if it would reach somebody beyond your authority, or switch on access grants you could not have written yourself. - **A role in use cannot be deleted.** Move its members to another role, or to none, first — so nobody's access widens as a side effect of tidying up. - **Names are read, not just stored.** Two roles cannot have names a colleague would read as one — "Deployer" and "deployer", or the same word in another letter width or with a lookalike letter — and no role may be named, or look like, a built-in one. - Owners never hold a custom role; promoting somebody to owner takes theirs off. If the organisation leaves Enterprise, existing custom roles keep applying. You can still take members off them and delete the ones nobody holds, but not define, edit or assign them. ## Tokens belong to people too A **CLI token** from `xecret login` acts as the person who created it: their role, their grants, nothing more. Revoking their membership makes every one of their devices stop working immediately. A **service token** is not a person. It is pinned to one project and environment, and its actions are recorded against the token's own name — never against whoever minted it. See [tokens](../api/tokens.md). Members can see and revoke their own devices under *Tokens → Your devices*. Admins can revoke anyone's. ## A sensible starting arrangement For a team of five to fifteen: - **Owners:** two people. One is a single point of failure; five is nobody being accountable. - **Admins:** whoever runs deployments. - **Developers:** everybody else. They get write access to development and staging automatically, and nothing in production. - **Production:** granted per person, per environment, and reviewed occasionally against the audit log. - **CI:** its own service tokens, read-only, one per project and environment. ## Next - [Core concepts](../concepts.md) — the hierarchy these rules apply to. - [The audit log](../security/audit-log.md) — checking what actually happened. - [Tokens](../api/tokens.md) — credentials in detail. --- # Secret references and environment inheritance Source: https://xecret-staging.playxoft.com/docs/features/references Summary: Build one secret from others with ${env.KEY} templates, and let an environment inherit from a parent — resolved on your machine, never on our servers. Updated: 2026-10-21 Two ways to stop copying the same value into five places: a **template** that refers to other secrets, and an environment that **inherits** from another. Both are on the Pro plan and above. ## Templates A secret whose type is `template` is text with references in it: ```text postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/app ``` | Reference | Means | |---|---| | `${KEY}` | `KEY` in the environment being read | | `${env.KEY}` | `KEY` in another environment of the same project | | `${project.env.KEY}` | `KEY` in an environment of another project | | `$${` | A literal `${` — not a reference | Names follow the secret-name rules (`[A-Za-z_][A-Za-z0-9_]*`). Anything that looks like a reference but is malformed — an unclosed `${`, an empty one, four parts — is refused when you save it, never discovered when a process starts. Set one from the dashboard by choosing the *template* type, or from the CLI: ```bash printf '%s' 'https://${API_HOST}/v1' | xecret secrets set API_URL --type template ``` ### Where an unqualified reference looks `${KEY}` resolves in the view of the environment **being read**, not the one the template is stored in. A template in a parent environment saying `${DB_HOST}` therefore picks up each child's own `DB_HOST` — which is usually exactly why you put it in the parent. A qualified `${staging.DB_HOST}` always means staging. ### What is checked before anything is stored Your client parses the template and declares its references beside the encrypted value. The server cannot read the template — it is end-to-end encrypted like every other value — so the declaration is what it checks, before storing, against the graph as it would be after the write: - every reference names an environment that exists and **that you can read** — a template is not a way round a grant you do not hold; - no loop: `A → B → A` is refused, in every view that can see the template; - no chain deeper than **five** references; - at most **50** references in one template. Deleting or renaming a secret a template depends on, or deleting an environment one points into, is refused with the template named. When a template is read, the client compares its decrypted text against the declaration stored beside it and refuses a mismatch, so the declaration a server holds cannot say something the text does not. What that check does **not** prove is that the server answered each reference honestly: which environment a slug means, and which stored secret a name means, are things the client learns from the server. A server that misbehaved could therefore answer `${staging.DB_HOST}` with a different value — but only one the reader can already decrypt, from an environment whose key they hold. It cannot make up a value, because it holds no key to encrypt one, and it cannot reach into an environment the reader has no key for. The [trust model](../security/trust-model.md) lists this as a residual risk. ## Inheritance An environment can name a **parent** in the same project (*environment settings → Inherits from*). Reading it gives the parent's secrets with the child's own on top: the child's value wins where both set a key, and a key the child does not set falls through. ```text base DB_HOST=db.internal LOG_LEVEL=info └─ staging LOG_LEVEL=debug → DB_HOST=db.internal LOG_LEVEL=debug └─ qa DB_HOST=qa-db → DB_HOST=qa-db LOG_LEVEL=debug ``` The chain is at most five parents deep, a loop is refused, and setting a parent needs read access to it. Clearing a parent is always allowed, on any plan. ## Production reading from elsewhere {#production-reading-from-elsewhere} Linking a **production** environment to one that is **not** — making it inherit from staging, or giving it a template that reads `${staging.DB_HOST}` — is allowed, and asked about. The reason is a trust consequence that is easy to miss: the link is a write path onto production for everybody who can change the source. A developer who may edit staging but not production changes what production reads the moment production inherits staging's value — without production's grants, its deny-by-default for developers, or anybody with production access being asked. So such a link is never made silently: - The first attempt is refused with a warning that names the source and **who can write it** — the people, and any write service tokens pinned to it. - The dashboard shows that warning and asks again (*Inherit anyway*, or saving the template a second time); the CLI takes `--confirm-link`; the API takes `confirmCrossesProtection: true`. - A confirmed link is recorded as its own `environment.link_crosses_protection` audit event, against production, naming the source. The same question is asked when an environment's production mark changes underneath a link that was already there, in whichever direction makes it cross: marking an environment production while it already inherits from, or already holds a template reading, one that is not; or removing the mark from an environment a still-production one — in this project or another — already inherits from or reads through a template. Nothing about the link moved; only which side now answers to production's rules did, and that is exactly as much a new write path onto (or out from under) production as making the link itself. Only the link being made, or the flag change that newly crosses one, is asked about. Re-saving a template with a reference it already had, a production environment inheriting from another production one, and staging reading from production ask nothing. If you want production's values to come only from places production's own rules govern, keep the production environments' parents and references production too — including a production environment of *another* project: it answers to that project's own writers, not to this one's, so a link into it is exactly as much a crossing as a link into staging. ## Resolution happens on your machine Every value stays encrypted under its own environment's key. `xecret run` and `xecret pull` fetch one bundle holding the environment, its ancestors and only the referenced secrets from elsewhere — a reference to `shared.DB_HOST` carries `DB_HOST`, not the rest of `shared` — open each with your own grant for that environment, and resolve the result locally. Nothing is re-encrypted and nothing moves between environments; the server never sees a resolved value. Every read is recorded where it happened: a pull of `staging` that reads a secret from `production` through a template writes a `secret.read` against production, naming the referring secret, so production's own audit trail is complete. ## Service tokens A service token is pinned to one environment and can read nothing else, so it could never resolve an inherited value or a reference into another environment. Rather than letting that fail during a deploy, xecret refuses it up front: - a token cannot be minted for an environment that inherits or that holds a template reaching outside it; - an environment with an active token cannot be given a parent, and cannot gain a template reaching outside it. Keep CI environments self-contained, or give the pipeline a login of its own. ## If your plan changes Nothing stops working. An environment keeps its parent and every template keeps resolving on any plan; only *setting* a parent or *writing* a template needs the plan that includes them. ## Next - [Personal overrides](overrides.md) — your own value for one key, on your machine only. - [Promotion and point-in-time restore](promotion-and-restore.md) — moving values between environments, and back in time. - [The trust model](../security/trust-model.md) — what the server can and cannot see. --- # Personal overrides Source: https://xecret-staging.playxoft.com/docs/features/overrides Summary: Use your own value for one secret on your machine — a local database instead of staging's — without changing the environment anyone else reads, or any CI run. Updated: 2026-10-21 A personal override is **your** value for one secret in one environment. When you run `xecret run`, it replaces the shared value on your machine and nowhere else. It is on the Pro plan and above. ## Setting one ```bash printf '%s' "postgres://localhost:5432/app" | xecret overrides set DATABASE_URL - xecret overrides list xecret run -- npm run dev ``` `run` prints which overrides it applied — names only — every time, so a local value is never mistaken for the shared one. Remove one with `xecret overrides unset DATABASE_URL`. The dashboard's environment page has a *My overrides* card that does the same. The name need not be a secret the environment already holds: an override is also how you try a new variable locally before proposing it. ## The rules - **Never visible to another member.** It is encrypted under a key derived from your own vault, not under the environment's key, so a teammate who can read every value in the environment still cannot open your override. Neither can the server. - **Never forged.** The same key is the only one that can make an override your CLI will accept, so a value cannot be slipped into your process in your name — not by a teammate, and not by the server. What a misbehaving server *could* do is keep handing back an override of yours it should have forgotten — the value you replaced, or one you removed — because an override carries no version your CLI could check. It can only ever be a value you set yourself, for that same name and environment; the [trust model](../security/trust-model.md) lists it among what xecret does not protect you from. - **Never part of a CI fetch.** A service token is refused every override route and no pull or bundle carries one: a pipeline always gets the shared values. A personal override that changed a production deploy would be the worst bug this product could have, so it is not a setting, it is the design. - **Never inherited.** An override belongs to exactly one environment; a child environment does not see its parent's. - **Only `run` applies them.** `xecret pull` and `xecret export` produce files that get committed and shared, which is the wrong place for one person's values. Each says on stderr when you have overrides it did not include. ## After a vault reset A vault reset gives your account a new key pair, and an override made under the old one no longer opens. `xecret run` stops rather than quietly using the shared value in its place — you set the override precisely so as not to get that value — and names the `xecret overrides unset` that removes each one. Set them again afterwards. ## Offline Overrides are fetched live, never cached. A run from the [offline cache](../cli/offline-cache.md) uses the shared values and says that it did. ## If your plan changes Overrides you already have keep working, and you can always list and remove them. Only setting a new one needs the plan. ## Next - [References and inheritance](references.md) — building one secret from others. - [CLI command reference](../cli/commands.md) — every flag of `xecret overrides`. --- # Environment promotion and point-in-time restore Source: https://xecret-staging.playxoft.com/docs/features/promotion-and-restore Summary: Promote values from staging to production with a masked diff, and put an environment back the way it was at an earlier moment — atomically, and without losing history. Updated: 2026-10-21 Two ways of changing many secrets at once, both on the Pro plan and above, and both built the same way: your browser decides what changes and re-encrypts each value, and the server writes the lot in **one transaction** — all of it, or none. ## Promotion *Environment → Promote from…* on the environment you are promoting **into**. Pick the source, and the dashboard shows the difference between the two: | Status | Means | |---|---| | Added | The source has it; the target does not | | Changed | Both have it, with different values | | Unchanged | Both have it, with the same value | | Removed | The target has it; the source does not — removed only if you tick it | Values are **masked** until you ask to see one. Choose what to include, check the preview, apply. What travels is the source's own secrets — not values it inherits from a parent — with their types. A template travels as a template, and the templates of one promotion are checked together, so a template can travel with the values it refers to. Your browser reads both environments, decrypts each value, and encrypts it again under the **target's** key for the target's row. The server cannot read either side and does not try; what it checks is everything it can: - you can read the source, and create and update — and, if you remove anything, delete — in the target; - every name written is one the source holds, with the source's type, and every name removed is one the target holds and the source does not; - the target's [secret policy](policies.md#secret-naming-and-strength) for every name the promotion introduces; - no template is left referring to nothing. A promotion is a person's act: service tokens cannot promote. It is recorded as one `environment.promoted` event with the counts, plus the ordinary `secret.created`, `secret.updated` and `secret.deleted` records for each secret, so every secret's own history shows it. ## Point-in-time restore *Environment settings → Restore to a point in time*. Pick a moment, and the preview — which always comes first — lists what restoring to it would do: | Action | Means | |---|---| | Update | The secret's value then differs from now; it gets its old value back | | Recreate | The secret existed then and has been deleted since; it comes back | | Delete | The secret was created since; it is removed | | Unchanged | Nothing to do | ### Restore never deletes history It writes **forward**. An old value comes back as a *new* version; a removed secret is soft-deleted, its history kept; a deleted one is created again beside the old record rather than undeleted, so the fact that it was deleted stays on the record. A restore is therefore itself restorable: undoing one is restoring to the moment before it. ### How far back | Plan | Window | |---|---| | Pro | 30 days | | Team | 180 days | | Enterprise | 365 days | A point outside your plan's window is refused, naming the plan whose window reaches it. ### Older keys A value written before a key rotation is encrypted under the key that was active then. Your browser opens it with your grant for that older key, which a rotation keeps. A value under a key you never held a grant for cannot be restored by you, so **a restore can be partial**: those secrets stay exactly as they are now while everything else goes back to the moment you picked. The preview warns you before anything is written and names each one, and the result lists them again as left as they are. Somebody who holds that key can restore them. A value whose key *was* given to you but that does not open in your browser is different: it stops the whole restore, because the server will not apply part of a moment it was asked to restore in full. Each value your browser sends back is checked against the one stored for that moment, so a stale or mistaken client cannot restore the wrong value. The preview reads the environment as it was, and is recorded as a `secret.read`. The restore itself is one `environment.restored` event with the moment and the counts, plus the per-secret records. ### What is not restored Names are not versioned: a secret renamed since keeps today's name. Notes and declared types are labels on the secret, not on a version, and are not rolled back either. ## Both features End-to-end encrypted environments only. Neither asks the plan on any read, and neither changes what a pull or a CI run receives except by the values it writes. ## Next - [Webhooks](webhooks.md) — `environment.promoted` and `environment.restored` events. - [The audit log](../security/audit-log.md) — every record these write. --- # Webhooks on secret change Source: https://xecret-staging.playxoft.com/docs/features/webhooks Summary: Get a signed HTTPS call when a secret is created, changed, rotated or deleted, or an environment is promoted or restored. Payloads carry names, never values. Updated: 2026-10-21 A webhook calls an HTTPS endpoint of yours when something changes in your organisation's secrets. Pro includes 3 endpoints, Team 25, Enterprise as many as you need. Set them up in *Organisation settings → Webhooks* (owners and admins). ## Events | Event | When | |---|---| | `secret.created` | A secret is created — directly, by an import, a promotion or a restore | | `secret.updated` | A secret's value changes, or it is renamed | | `secret.deleted` | A secret is deleted — directly, or by a promotion or a restore | | `secret.rotated` | An earlier version is made current again | | `environment.promoted` | A [promotion](promotion-and-restore.md#promotion) lands, with counts | | `environment.restored` | A [point-in-time restore](promotion-and-restore.md#point-in-time-restore) lands, with counts | Writing a value identical to the current one changes nothing and sends nothing. Each endpoint subscribes to the events it wants. ### One event per kind of change, not per secret An import, a promotion or a restore can change hundreds of secrets in one go. It sends **one event of each kind it caused** — one `secret.created` for everything it created, one `secret.updated`, one `secret.deleted` — listing the secrets in `data.secrets`, with `data.counts` saying how many: ```json { "type": "secret.created", "timestamp": "2026-10-20T09:30:00.000Z", "data": { "organization": { "id": "0193…", "slug": "acme" }, "project": { "id": "0193…", "slug": "checkout-api" }, "environment": { "id": "0193…", "slug": "staging" }, "secrets": [ { "name": "SENTRY_DSN", "version": 1 }, { "name": "STRIPE_WEBHOOK_SECRET", "version": 1 } ], "counts": { "created": 2 }, "actor": { "type": "user", "id": "0193…", "label": "dana@acme.example" } } } ``` An event about one secret carries `data.secret`; an event about several carries `data.secrets` instead — never both — so handle both shapes. The list holds at most 1,000 entries, which is also the most one batch can change. ## The payload never contains a value Names, slugs, version numbers, counts and who did it — the same vocabulary as the [audit log](../security/audit-log.md). Anybody who wants the value fetches it with a credential, and is audited doing so. ```json { "type": "secret.updated", "timestamp": "2026-10-20T09:30:00.000Z", "data": { "organization": { "id": "0193…", "slug": "acme" }, "project": { "id": "0193…", "slug": "checkout-api" }, "environment": { "id": "0193…", "slug": "production" }, "secret": { "name": "STRIPE_SECRET_KEY", "version": 7 }, "actor": { "type": "user", "id": "0193…", "label": "dana@acme.example" } } } ``` ## Verifying the signature Every delivery is signed with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme — the same one xecret consumes from its own payment provider — using the endpoint's `whsec_…` secret. It is shown **once**, when the endpoint is created or its secret rotated; xecret stores it encrypted and never shows it again. Three headers arrive with each request: `webhook-id`, `webhook-timestamp` (Unix seconds) and `webhook-signature` (`v1,` and a base64 HMAC-SHA256). Sign `id.timestamp.body` with the base64-decoded part of the secret after `whsec_`, compare in constant time, and reject a timestamp more than five minutes away from your clock: ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(secret: string, headers: Headers, body: string): boolean { const id = headers.get('webhook-id') ?? ''; const timestamp = headers.get('webhook-timestamp') ?? ''; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest(); return (headers.get('webhook-signature') ?? '') .split(' ') // Only `v1,` signatures count: anything else is a scheme this code does // not verify, not a signature to compare. .filter((part) => part.startsWith('v1,')) .map((part) => Buffer.from(part.slice('v1,'.length), 'base64')) .some((given) => given.length === expected.length && timingSafeEqual(given, expected)); } ``` Verify against the raw body, before parsing it. The official Standard Webhooks libraries do all of this for you. ## Delivery and retries A 2xx response is a success; anything else — including a redirect, which is never followed — is a failure. Each attempt waits up to ten seconds. A failed delivery is retried with backoff — 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after each failure, give or take the five minutes between delivery sweeps — then abandoned. A retry sends the same body, re-signed with a fresh timestamp, under the same `webhook-id`, so use it to discard duplicates. The first few deliveries of a change are sent straight away; anything left over, and every retry, is sent by the next delivery sweep. A sweep takes organisations **in turns** — every organisation's oldest delivery before anybody's second — so another organisation's large backlog never holds yours up, and keeps sending until nothing is due or its time is up, when the rest waits for the next sweep. An endpoint that keeps failing — 15 failures in a row spanning at least a day — is **disabled automatically**, and the organisation's owners and admins are emailed. Re-enable it from the dashboard once it is fixed. Each endpoint has a delivery log — status, attempts, the response code, the body sent — and a *Send test* button that sends one `webhook.test` delivery. Endpoint URLs must be `https://`, and may not point at a private, loopback or link-local address. ## If your plan changes Your endpoints are kept. On a plan with no webhooks they are paused — nothing is sent, and the dashboard says why — and resume when the plan includes them again. ## Next - [Promotion and restore](promotion-and-restore.md) — the environment-wide events. - [The audit log](../security/audit-log.md) — the same facts, kept forever. --- # Policies and new-address read alerts Source: https://xecret-staging.playxoft.com/docs/features/policies Summary: Require IP allowlists and short lifetimes on tokens, enforce secret naming and strength rules, disable plaintext export, and get told when a new address reads production. Updated: 2026-10-21 Rules an organisation sets for itself, and a notification it can ask for. Each is off until somebody turns it on. ## The rule every policy follows The plan decides who may **turn a policy on or tighten it**. Once on, it is enforced whatever the plan says next: a downgrade never switches a security control off, because somebody may be relying on it. Loosening a policy, or switching it off, never needs a plan. No policy ever stops a build from running: `xecret run`, on a laptop or in CI, works tomorrow if it worked yesterday, whatever was decided in between. The one policy that refuses a read is [disabling plaintext export](#disabling-plaintext-export), and only for the commands whose whole job is to write the values into a file — `pull`, `export` and the dashboard's download. One more guard rail sits beside the policies, though it is not one you switch on: production reading values from an environment that is not production — by inheriting from it, or through a template — must be confirmed, because it lets the source's writers change what production reads. See [Production reading from elsewhere](references.md#production-reading-from-elsewhere). ## Token policy *Organisation settings → Token policy*, owners and admins, Pro and above. | Rule | Effect | |---|---| | Require an IP allowlist | A new service token is refused unless it names the addresses it may be used from | | Cap token lifetime | A new service token must expire within the cap, and a new `xecret login` is issued with the cap as its expiry | The rules apply when a token is **minted**, never when one is used: turning the policy on must not break every pipeline at once. Tokens minted before it keep working, and the token list marks the ones outside the policy so you can replace them deliberately. Setting an allowlist or an expiry on an individual token has always been available on every plan, and still is. ## Secret naming and strength {#secret-naming-and-strength} *Organisation settings → Secret naming and strength*, owners and admins, Team and above. It ships with a sensible default ruleset, switched off: | Rule | Default | |---|---| | Names must match | `[A-Z][A-Z0-9_]*` — the whole name | | Minimum entropy | 64 bits, for names containing `SECRET`, `PASSWORD`, `PASSWD`, `TOKEN`, `PRIVATE_KEY` or `API_KEY` | | Forbidden words | `changeme`, `password`, `letmein`, `default`, `123456`, in any case | The policy can apply to every environment or only to production ones. **The name rule** is enforced by the server on every write that introduces a name — a create, a rename, an import, a promotion — whatever client sent it. Because the server runs it on every such write, a pattern is refused when it is saved if it could make one check slow: a repeated group that itself repeats, like `(A+)+`, or more than two repeated parts in a row, like `A*B*C*`. Both can almost always be written as a single class — `[ABC]*`. **The value rules** need the value, which the server never sees in an end-to-end encrypted environment. The dashboard, `xecret secrets set` and `xecret import` apply them before encrypting, from one implementation and one set of shared test fixtures, so they accept and refuse the same values; in a server-side-encrypted environment the server enforces them as well. A modified client could skip them, and this page does not pretend otherwise: they are a guard rail for honest mistakes, not a boundary against a member who means to get round them. A [template](references.md)'s text is not a credential of its own, so it is read differently: the entropy rule is not asked of it, and the forbidden words are looked for in its literal text only — `postgres://app:${DB_PASSWORD}@db` does not contain the word "password", because `DB_PASSWORD` is a reference's name. The secrets a template names are held to the rules where they are written. **If the policy cannot be read**, a client refuses the write rather than skipping the rules, and says so: the dashboard while the policy is loading or after it failed to load, the CLI when the request fails. Only a server too old to have policies at all is treated as having none. A write that carries no value — a rename, a note — is unaffected, because the server checks the name rule itself. Existing names and values that break the rules are **warnings** in the dashboard, never errors. A policy adopted today is not a reason to make yesterday's value unreadable. Entropy is estimated as the value's length times the Shannon entropy of its own characters — a 32-character random token scores about 150 bits, `password1` about 27. It rewards length and variety and is easy to explain, which matters more for a rule people have to satisfy than precision does. ## Disabling plaintext export {#disabling-plaintext-export} *Environment settings → Plaintext export*, owners and admins, Team and above. With export disabled, `xecret pull`, `xecret export` and the dashboard's download refuse for that environment, naming the policy and who can change it. `xecret run` is never affected: it puts values into a process without writing them anywhere, which is the habit this policy exists to encourage. The setting follows the **values**, wherever they are read from. A `pull` of staging that [inherits](references.md#inheritance) from production, or whose template reaches `${production.DB_PASSWORD}`, writes production's values to a file, so it is refused when production disables export — and the refusal names production, whose admins can change it. An environment that is only walked through to reach a reference, and contributes no value of its own, does not count. The dashboard's download of an environment's own values is decided by that environment alone, because nothing else is in it. What it can and cannot do, plainly: anybody who may read a value can still write it down — they hold it in plaintext the moment they read it. What the policy removes is the one-command route from a vault to an unencrypted file on a laptop, which is how most leaked `.env` files begin. ## New-address read alerts *Environment settings → Read alerts*, Pro and above. The first time an environment is read from an address — or by a device — that has not read it before, the organisation's owners and admins are emailed. A CLI login and a CI token each count as a device; a browser counts as its person; an IPv6 address counts as its /64, so a laptop's rotating privacy addresses are one address. - **On by default for production**, off elsewhere. Each environment can be switched either way, or back to the default. - **A read is a read of every environment it draws values from.** A pull of staging that inherits from production, or whose template reaches `${production.DB_PASSWORD}`, is noted as a read of production under production's setting — and of staging under staging's. - **Digest-throttled**: at most one email an hour per organisation. Everything else seen in the hour waits for the next digest, so a CI fleet on rotating addresses produces one email listing them, never one per runner. - **Recorded after the response.** A read is never slower, and never refused, for being noted. The environment's settings list its recent new addresses, whether or not mail reached anybody. If the plan stops including alerts, nothing is recorded or sent until it does again. ## Next - [Teams, roles and access](../guides/teams.md) — who may change these. - [Service tokens](../api/tokens.md) — the credentials the token policy governs. - [The audit log](../security/audit-log.md) — `org.policy_changed` and `environment.policy_changed`. --- # HTTP API overview Source: https://xecret-staging.playxoft.com/docs/api Summary: The contract the dashboard, CLI and CI all speak — authentication, request shape, error codes, pagination, rate limits and CSRF. Updated: 2026-08-16 Everything the dashboard does, the CLI does, and CI does, it does through this API. There is no private back channel. You will rarely need it directly — `xecret run` is the supported path — but it is here, documented, for tooling you build yourself. ## Shape Base path `/api`. JSON in, JSON out, and `Cache-Control: no-store` on every response. Resources are addressed by **slug**, never by id: ```http GET /api/orgs/acme/projects/checkout-api/environments/production/secrets/DATABASE_URL ``` This is a security property rather than an aesthetic one. A slug only means something inside its parent, so the path itself carries the ownership chain and every handler must resolve it top-down through your membership. An id-addressed route (`/api/secrets/{uuid}`) invites the opposite: one lookup by primary key, with the ownership check as a separate step somebody can forget. That omission is the classic broken-access-control bug, and it is the one this product cannot afford. The cost is one join per level, paid once per request. ## Authentication Three credentials. **A request presents exactly one.** | Credential | Carried in | Acts as | Used by | |---|---|---|---| | Session | `__Host-xecret_session` cookie | you | the dashboard | | CLI token | `Authorization: Bearer xct_…` | you | the `xecret` CLI | | Service token | `Authorization: Bearer xst_…` | the token itself | CI | A cookie **and** a bearer token on the same request is a rejected request, not a precedence question. Silently picking one is how a cookie that a browser attaches automatically ends up authorising a call the client believed was bearer-authenticated. ```bash curl https://xecret.playxoft.com/api/tokens/self \ -H "Authorization: Bearer $XECRET_TOKEN" ``` Full detail on each credential, including what a service token may and may not do: [tokens](api/tokens.md). ### CSRF Cookie-authenticated mutations require a double-submit pair: the value of the `__Host-xecret_csrf` cookie echoed in an `X-Xecret-Csrf` header. Bearer-authenticated requests do not need it, and must not send it — they carry no ambient credential for a browser to attach on your behalf, which is the entire thing CSRF protection defends against. ### Some things only a session can do Minting a credential is never something a credential can do. These endpoints require a browser session and reject bearer tokens: - creating a service token or a CLI authorization code - inviting a member - creating or deleting an organisation - deleting an account The rule behind it: **a bearer credential may not mint further credentials.** A leaked CI token must not be able to grow itself into a permanent one. ## Errors ```json { "error": { "code": "validation_failed", "message": "Request body failed validation.", "requestId": "8f2a…", "fields": [ { "field": "name", "message": "Secret name cannot start with a digit." } ] } } ``` | Code | Status | Meaning | |---|---|---| | `bad_request` | 400 | Malformed request | | `validation_failed` | 422 | Body failed schema validation; `fields` is populated | | `unauthenticated` | 401 | No credential, or an invalid one | | `forbidden` | 403 | Authenticated, membership established, action not permitted | | `not_found` | 404 | Does not exist, is in another organisation, **or** is not visible to you | | `conflict` | 409 | Name or slug taken; version race | | `payload_too_large` | 413 | Body over 1 MB, or a secret over 64 KB | | `rate_limited` | 429 | Bucket exhausted | | `csrf_failed` | 403 | Double-submit pair missing or mismatched | | `session_locked` | 403 | Authenticated, but the session's PIN has not been entered recently | | `unavailable` | 503 | Misconfigured deployment — a missing binding, an unreachable database | | `internal_error` | 500 | Unhandled fault | ### 404 and 403 are not interchangeable 403 is returned **only** once your membership in the organisation is already established, so it reveals nothing you did not already know. Everything else — wrong tenant, no grant, genuinely absent — is 404. A client that could tell those apart could enumerate another company's projects by watching which ones answer 403 rather than 404. ### Messages are fixed strings `message` is never derived from an exception, a database error, or the input that was rejected. In this product the rejected input may itself be a secret value. `requestId` is what correlates a report with the server's logs. ## Pagination Keyset, not offset: ```http GET /api/orgs/acme/.../secrets?limit=50&cursor= ``` ```json { "data": [ … ], "nextCursor": "…" } ``` `nextCursor` is `null` on the last page. `limit` is clamped to 200. Offset pagination re-scans on every page and shifts under concurrent inserts, so a row can be skipped or repeated between pages. On the append-only audit table, that degradation is severe. ## Rate limits | Bucket | Applies to | Counted per | |---|---|---| | `RL_LOGIN` | sign-in, PIN operations | IP + identity | | `RL_CLI_TOKEN` | CLI token creation and exchange | user | | `RL_INVITE` | invitations | organisation | | `RL_SECRET_READ` | reveal and pull | actor | | `RL_SERVICE` | service-token requests | token | | `RL_MUTATION` | every other write | actor | Exhausting one returns 429. Counters are per-datacentre rather than global — they are abuse control, not a security boundary. What actually protects a secret is authentication, authorisation and the audit trail. ## What is audited Every mutation, every decryption, and **every denial**. A system that records only what succeeded cannot detect an attack in progress. Audit metadata is typed as a fixed allowlist of fields with no catch-all, so a secret value cannot be placed into a record — the type system rejects it rather than a reviewer having to notice. See [the audit log](security/audit-log.md). ## Next - [Endpoint reference](api/reference.md) — every route. - [Tokens](api/tokens.md) — how to get a credential and what it can do. - [The CLI](cli.md) — the supported way to use all of this. --- # API endpoint reference Source: https://xecret-staging.playxoft.com/docs/api/reference Summary: Every HTTP endpoint — auth, organisations, projects, environments, secrets, bulk read, import and export, members, tokens and the audit log. Updated: 2026-10-20 Every route, grouped the way you will look for them. Paths are relative to `/api`, and `{…}` segments are slugs. Read [the API overview](../api.md) first for authentication, errors and pagination — none of it is repeated here. ## Version | Method | Path | Notes | |---|---|---| | `GET` | `/version` | What is deployed. No credential required. | Answered at **both** `https:///api/version` and `https:///version` — the same body, no redirect between them. The second is there because a version check is the one request made by someone holding a hostname and nothing else. ```json { "name": "xecret", "version": "0.1.0", "commit": "a1b2c3d", "builtAt": "2026-08-17T12:00:00Z" } ``` The only unauthenticated `GET` that answers with data rather than a sign-in redirect, and the only endpoint that touches no database. Use it to check what a deployment is running before reporting a bug against it, and to watch a deploy roll out. `commit` is `git describe --always --dirty`, so a build made from a modified tree says `a1b2c3d-dirty`. Both `commit` and `builtAt` read `unknown` on a build that did not come from `scripts/deploy-web.sh` — a local `next build`, or CI. It reports the build, never the install: no environment name, no bindings, no dependency versions, no identity-provider or database detail. Those describe how one deployment is configured, which is not public. ## Auth | Method | Path | Notes | |---|---|---| | `GET` | `/auth/login` | Starts a sign-in: sets a short-lived signed cookie and redirects (**307**) to the identity provider's hosted sign-in page. Optional `?next=` (a path on this site to land on afterwards), `?hint=sign-up`, `?org=` (start that organisation's single sign-on directly, if it has one). A link, not an API call. | | `GET` | `/auth/callback` | Where the identity provider sends the browser back. Checks the flow belongs to this browser, verifies the sign-in with the provider, creates the account on first sign-in, bootstraps a personal organisation, sets the session and CSRF cookies, and redirects (**303**) to `next`. | | `DELETE` | `/auth/session` | Signs out. Idempotent. Answers `{ logoutUrl }`: the sign-in provider's logout address for the session you signed in with, which the dashboard opens so that you are signed out there too — or `null` when there is none to end. | | `GET` | `/auth/me` | You, your organisations, your role in each — with `sso: { required, blocked }`, whether the organisation requires single sign-on and whether that refuses the credential you are using — and the PIN state. | | `GET` `POST` | `/auth/pin` | Read the PIN state; set or change the PIN. Changing requires the current one. | | `POST` | `/auth/pin/unlock` | Body `{ pin }`. Unlocks this session for 8 hours. | | `POST` | `/auth/pin/lock` | Locks this session, or all of them with `{ everywhere: true }`. Does not sign you out. | | `POST` | `/auth/pin/reset` | Emails a single-use reset link to your own address. Returns `{ sent, reason? }` — read the flag, not the status. | | `POST` | `/auth/pin/reset/confirm` | Body `{ token, pin }`. Needs the emailed token **and** a session for the same account. | | `GET` | `/auth/sessions` | Your active sessions. Never returns a token hash. | | `DELETE` | `/auth/sessions` | Sign out everywhere. Optional `?except=current`. | | `DELETE` | `/auth/account` | Deletes your account. Body `{ confirm: "" }`. Browser sessions only, PIN-gated. **409** if you are the only active owner of an organisation other people are in. | Signing in is a sequence of browser navigations, not an API call: nothing on this server accepts an identity-provider token, and there is no endpoint to post a password to. A refused sign-in redirects to `/sign-in?error=`, which shows a fixed sentence for that code. The specific reason is logged and never shown: telling a caller which part of a forged request to fix is a gift. ### Re-authentication for a vault reset Resetting a vault ends access to everything it held — the old vault is kept, recoverable, for seven days, then deleted for good — so it asks for proof twice, in order: a fresh sign-in (`/auth/login?reauth=1`, which makes the provider ask for a credential even if it remembers you), then a one-time code emailed to you. Browser sessions only, and usable while the vault is locked — which is the point. A refused re-authentication is a **403**, never a 401, so it does not sign you out. | Method | Path | Notes | |---|---|---| | `GET` | `/auth/reauth` | Where this session's attempt stands: `{ signedIn, signedInUntil, codeSent, email }`. Sends nothing. | | `POST` | `/auth/reauth/start` | Emails the code. Only within 10 minutes of the fresh sign-in, and at most three times per sign-in (then **403**: sign in again). **204**. | | `POST` | `/auth/reauth/verify` | Body `{ code }` (six digits, from the email) — or, after a `{ mfaRequired: true, mfaToken }` answer, `{ totp, mfaToken }` with the code from your authenticator app. Returns `{ reauthProof, expiresAt }` — single-use, five minutes, good for this session only. Every guess counts: five per attempt, ten per account per day; past the daily limit, **429**. | | `GET` | `/auth/vault/reset/preflight` | What a reset would do with the vault's escape archive, asked before you confirm: `{ resettable, archive: { deliverable, reason } }`. `reason` is `null`, `too-large` (the archive is over 8 MiB, and then `estimatedBytes` and `maxBytes` are included) or `mail-not-configured`. `resettable` is `false` on a production deployment with no email set up: it resets no vault. An unlocked session also gets the size and the counts of environments and secrets. **404** with no vault. Usable while locked. | | `POST` | `/auth/vault/reset` | Body `{ confirm: "reset my vault", reauthProof, proceedWithoutArchive? }`. First, before the proof is spent: **503** on a production deployment with no email set up, whatever the body says; **409** when the archive cannot be emailed (too large, or no email outside production) and `proceedWithoutArchive` is not `true`. Then it spends the proof, emails the escape archive to your address (at most two parts; a notice instead when you chose to go without), and resets the vault — keeping the old one, recoverable, for seven days. **503** if an email cannot be sent, **409** if your vault changed in between; nothing is reset in either case, and a retry needs a new proof. **404** with no vault. Returns `{ vault, archive, retained }`: `archive` is `{ emailed: true, parts, to }` or `{ emailed: false, reason }`; `retained` is `{ id, retainedUntil, grantCount }`. **This is not recovery** — nothing is decrypted or restored. | ### Recovering a reset vault Inside its seven days, a reset vault can be brought back into the vault you have now, in the browser, with its old passphrase or one of its recovery codes (see [Vault reset and archive](/docs/security/vault-archive)). These endpoints need an **unlocked** session — re-sealing the old keys into the current vault needs it open — and answer only the vault's owner, only inside the window: the same **404** for anything else, a malformed id included. | Method | Path | Notes | |---|---|---| | `GET` | `/auth/vault/retained` | Your reset vaults still inside their window: `{ retained: [{ id, retainedAt, retainedUntil, recoveredAt, grantCount, environmentCount, recoveryCodesRemaining }] }`. | | `GET` | `/auth/vault/retained/{id}` | What the browser needs to open one: its passphrase wrap, salt and parameters, encrypted private key, and the grants of environments you may still read. Never its recovery wraps. | | `POST` | `/auth/vault/retained/{id}/recovery-wrap` | Body `{ lookupHash }` → `{ wrap }`: the recovery wrap one of its codes opens. Every miss is one **400**, counted against the retained vault's own lockout; **429** while locked out. | | `POST` | `/auth/vault/retained/{id}/restore` | Body `{ ukUnlockVerifier, grants }`: its keys, re-sealed in the browser to your current vault and signed by it, with proof the old vault was opened. A wrong proof is **403** and counted; **429** while locked out. Returns `{ restored, skipped }`. There is no endpoint that deletes a reset vault. | ## CLI authorization How `xecret login` obtains a token. An RFC 8252-style loopback flow with PKCE, against this server rather than the identity provider. | Method | Path | Notes | |---|---|---| | `POST` | `/cli/authorize` | Session + CSRF only. Body `{ orgSlug, deviceName, codeChallenge }`. Mints a single-use code, 10-minute TTL, hashed at rest. | | `POST` | `/cli/token` | Public — the caller holds no credential yet. Body `{ code, codeVerifier }`. Returns the `xct_` token exactly once. Every failure is the same fixed 401. | | `DELETE` | `/cli/token` | The token revokes itself — this is `xecret logout`. Idempotent. | The code is consumed **before** the PKCE check, so a failed binding kills it rather than leaving it guessable. ## Organisations | Method | Path | Notes | |---|---|---| | `GET` | `/orgs` | Your memberships. Refused for a service token, which is pinned to one organisation. | | `GET` | `/orgs/availability?slug=` | `{ slug, available, reason?: invalid\|reserved\|taken }`. A snapshot, not a reservation. | | `POST` | `/orgs` | Body `{ name, slug? }`. Name is 25 characters at most. Session + CSRF only. Creates the organisation, your owner membership and the organisation's key in one transaction. No project is seeded — create the first one from the dashboard, which generates its environment keys in your browser. | | `GET` | `/orgs/{orgSlug}` | Any active member. | | `PATCH` | `/orgs/{orgSlug}` | The name only. A slug change is refused with an explanation rather than ignored. | | `DELETE` | `/orgs/{orgSlug}` | Soft delete. Owners only, browser session only, body `{ confirm: "" }`. | ## Projects and environments | Method | Path | |---|---| | `GET` `POST` | `/orgs/{orgSlug}/projects` | | `GET` `PATCH` `DELETE` | `/orgs/{orgSlug}/projects/{projectSlug}` | | `GET` `POST` | `…/projects/{projectSlug}/environments` | | `GET` `PATCH` `DELETE` | `…/environments/{envSlug}` | Both deletes are soft. A hard delete would orphan the audit records saying the thing existed and who removed it. Creating a project or an environment also creates the encryption keys, in the same transaction. A project arrives with `development`, `staging` and `production`, so `POST /orgs/{orgSlug}/projects` carries the keys for all three: `{ name, slug?, description?, environments: [{ slug, id, keys: { grant } }] }`, where each `slug` names which of the three defaults the entry is for. Both creates need a browser session with an unlocked vault — the keys are generated there and sealed to you — and are refused for a CLI or service token. An environment without a key cannot hold a secret and cannot be repaired. ## Secrets | Method | Path | Notes | |---|---|---| | `GET` | `…/environments/{envSlug}/secrets` | **Masked.** Names, versions, timestamps, who updated. No ciphertext leaves the database. | | `POST` | `…/secrets` | Create. Body `{ name, value, note?, valueType? }`. | | `GET` | `…/secrets/{name}` | **Reveal.** Decrypts one value. Audited as `secret.revealed` every time. | | `PATCH` | `…/secrets/{name}` | Appends a new version. Body `{ value, valueType? }`. A value identical to the current one is a no-op, detected without decrypting. | | `PUT` | `…/secrets/{name}` | Metadata only — `{ name?, note?, valueType? }`. Appends no version: declaring a type is not a rotation, and neither is a rename. | | `DELETE` | `…/secrets/{name}` | Soft delete. | | `GET` | `…/secrets/{name}/versions` | History. Metadata only. | | `GET` | `…/secrets/{name}/versions/{version}` | Reveal one historical version. Audited, with the version recorded. | | `POST` | `…/secrets/{name}/restore` | Body `{ version }`. Re-appends an earlier value as a new version; never rewrites history. | The masked listing and the reveal endpoint are **separate routes on purpose**. Decryption happens in exactly one handler, so "where can a plaintext secret be produced?" has a one-line answer a reviewer can verify by searching the code. ## Bulk read The path `xecret run` depends on. | Method | Path | |---|---| | `GET` | `…/environments/{envSlug}/pull?format=env\|json\|yaml\|shell\|docker` | One environment, every current secret, decrypted server-side. Its budget is at most three database queries and **zero** outgoing network calls, constant in the number of secrets. Audited **once per call** as `secret.read` with a count — not once per secret, which would make a 200-secret pull write 200 audit rows and turn the audit table into a denial-of-service surface against itself. ## Import and export | Method | Path | Notes | |---|---|---| | `POST` | `…/environments/{envSlug}/import` | Body `{ content, format?, strategy, dryRun }`. `dryRun: true` returns the plan and writes nothing. | | `GET` | `…/environments/{envSlug}/export?format=…` | The same data as `pull`, as a file download. | The dry run and the real import call the same planning function, so the preview cannot disagree with the outcome — including a refusal for the plan's limit on secrets per environment. ## Plan limits Every plan's limits apply from the day they are published, and only to creating something. At a limit, the next one is refused with **403** `plan_limit`, whose `plan` block names the resource, the limit, your plan and the plan that would allow more: | Limit | Refused when you | |---|---| | `projects` | create a project | | `environmentsPerProject` | create an environment | | `secretsPerEnvironment` | create a secret, or import, promote or restore new names into an environment (a batch is weighed whole) | | `serviceTokens` | mint a service token | | `cliDevicesPerUser` | approve or complete an `xecret login` — revoke a device you no longer use to make room | | `webhooks` | add a webhook endpoint | Only live things count: deleting a project or environment, or revoking a token, frees its place, and an expired token holds none. Nothing you already have past a limit — after a downgrade, say — is deleted: it stays readable and editable, and only adding more is refused. The exception is webhooks on a plan that allows none, which are kept but paused — they send nothing, and cannot be edited, switched back on or tested until the plan allows them (switching one off or deleting it always works). Creating an organisation past the account's limit, and inviting past the organisation's seats, answer **409** `conflict` instead, with the same information in the message. ## Environments and policies The features in [Environments and policies](../features/references.md). Every write here is end-to-end encrypted: the client encrypts, the server checks shapes, names and permissions, and stores. A plan refusal is **403** `plan_limit` naming the plan that would allow it; none of these is ever asked of a read of the environment itself. | Method | Path | Notes | |---|---|---| | `PUT` | `…/environments/{envSlug}/parent` | Body `{ parent: envSlug \| null }`. Inherit from another environment of the project, or stop. Setting one needs Pro and read access to the parent; refused for a loop, a chain past five, an environment a service token is pinned to, or a change that would leave a template unresolvable. | | `GET` | `…/pull?bundle=2` | What a client that resolves inheritance and templates asks for. An environment that uses either answers **409** `resolution_unsupported` to a client that does not ask. Every environment the resolution reads must be one you can read. | | `GET` | `…/pull?bundle=2&layers=none` | The environment's own values only — templates marked, nothing resolved, no other environment read. What the dashboard asks for. Needs read access to this environment alone. | | `GET` | `…/environments/{envSlug}/overrides` | Your own [personal overrides](../features/overrides.md): `{ overrides: [{ name, ciphertext, updatedAt }], canSet, upgradeTo }`. A person's credential only — a service token gets **403**. | | `PUT` `DELETE` | `…/overrides/{name}` | Body `{ ciphertext }` (an `xk2.gcm.` blob under your override key). Setting needs Pro; removing never does. | | `POST` | `…/environments/{target}/promote` | Body `{ source, writes, removals?, dryRun }`. [Promotion](../features/promotion-and-restore.md#promotion), atomic. `writes` are client-encrypted entries for the target, of names the source holds. | | `GET` | `…/environments/{envSlug}/restore-point?at=` | The [restore](../features/promotion-and-restore.md#point-in-time-restore) preview: each secret at the instant, its action, and your grants for the keys its old values were written under. Audited as a read. | | `POST` | `…/environments/{envSlug}/restore-point` | Body `{ at, writes, dryRun }`. Each entry is checked against the instant's value tag. Past the plan's window: **403** `plan_limit`. | | `PATCH` | `…/environments/{envSlug}/policy` | Body `{ plaintextExport?: allowed\|disabled, readAlerts?: boolean \| null }`. Owners and admins. Disabling export needs Team; switching alerts on, Pro. | | `GET` | `…/environments/{envSlug}/read-sightings` | Recent new addresses that read the environment, and whether alerts are on. Owners and admins. | | `GET` | `/orgs/{orgSlug}/policies` | The [token and secret policies](../features/policies.md), and which the plan lets you turn on. Readable by every member and service token. | | `PATCH` | `/orgs/{orgSlug}/policies` | Body `{ tokens?, secrets? }`, each whole. Owners and admins, from a session. Tightening needs the plan; loosening never does. | | `GET` `POST` | `/orgs/{orgSlug}/webhooks` | [Webhooks](../features/webhooks.md). Body `{ url, description?, eventTypes }`. The signing secret is returned **once**. | | `GET` `PATCH` `DELETE` | `/orgs/{orgSlug}/webhooks/{webhookId}` | `PATCH` takes `url`, `description`, `eventTypes`, `enabled`. | | `POST` | `…/webhooks/{webhookId}/rotate-secret` | A new signing secret, returned **once**. | | `POST` | `…/webhooks/{webhookId}/test` | One `webhook.test` delivery, attempted inline. | | `GET` | `…/webhooks/{webhookId}/deliveries` | The delivery log, newest first. | A server-mode environment whose plaintext export is disabled refuses `…/export` and document-format pulls with **403** `export_disabled:`, and marks the JSON pull `xecret run` reads with `x-xecret-plaintext-export: disabled`. ## Members, invitations and grants | Method | Path | Notes | |---|---|---| | `GET` | `/orgs/{orgSlug}/members` | Names, emails, roles (with any custom role, by name), status, join dates, seat count. Never access grants — those are per-project and belong on a member's own page. | | `POST` | `/orgs/{orgSlug}/members` | Invite by email. Session + CSRF only. Enforces the role hierarchy and the seat limit. Returns the acceptance link **once**. | | `PATCH` | `/orgs/{orgSlug}/members/{memberId}` | Exactly one of `{ role }`, `{ status }` or `{ customRoleId }` per request — they are different acts with different audit records. `customRoleId: null` takes a custom role off. Refuses self-changes; last-owner guarded. Putting somebody on a custom role needs the Enterprise plan; taking one off never does. An owner cannot hold one (**409**). | | `DELETE` | `/orgs/{orgSlug}/members/{memberId}` | Refuses self-removal; last-owner guarded. Grants cascade. | | `PUT` `DELETE` | `…/members/{memberId}/grants` | One grant, addressed by `{ projectSlug, environmentSlug?, accessLevel }`. Absent or null `environmentSlug` means the whole project. | | `GET` | `…/members/{memberId}/access` | The effective-permission preview: every project and environment with the resolved level and the rule that produced it. Computed by the same function enforcement calls. | | `GET` | `/orgs/{orgSlug}/authority` | What *you* could grant: per environment, the highest level a grant you write there may carry. The dashboard caps its level controls with it. | | `GET` | `/orgs/{orgSlug}/invitations` | Open invitations, expired ones included. | | `DELETE` | `/orgs/{orgSlug}/invitations/{invitationId}` | Withdraws one; the emailed link stops working at commit. | | `POST` | `/invitations/lookup` | Public — the holder may have no account. Body `{ token }`. Returns the organisation name, invited address, role, state and expiry, and nothing else. | | `POST` | `/invitations/accept` | Session + CSRF. Body `{ token }`. The session's address must match the invited one. | ## Custom roles Enterprise only. A custom role narrows a built-in role — it can never give anybody more than the role it is based on. See [Teams and access](../guides/teams.md#custom-roles). | Method | Path | Notes | |---|---|---| | `GET` | `/orgs/{orgSlug}/roles` | Every role with how many members hold it, and whether the plan lets you define and assign them. Needs the manage-members permission. Never plan-gated. | | `POST` | `/orgs/{orgSlug}/roles` | Body `{ name, baseRole: admin\|developer\|viewer, allowedActions, accessCeiling?: { nonProduction, production } \| null }`. Names are unique per organisation as a reader sees them — two names that differ only in case, letter width, spacing or lookalike letters are one name (**409**) — and may not be, or imitate, a built-in role's. Invisible characters, and Latin letters mixed with Cyrillic or Greek ones, are refused (**422**), as is a list naming an action the base cannot perform. | | `PATCH` | `/orgs/{orgSlug}/roles/{roleId}` | Any of the fields above. Refused if anybody holding the role is beyond your authority, or holds access grants beyond yours that the change would switch on. A change that changes nothing writes nothing. | | `DELETE` | `/orgs/{orgSlug}/roles/{roleId}` | Only a role nobody holds — otherwise **409**: move its members to another role, or to none, first. Never plan-gated. | ## Single sign-on SAML and OIDC single sign-on through WorkOS, set up by your IT admin in WorkOS's setup portal. From a browser session only (a CLI token is refused). Owners and admins can read the status and switch a policy off; only **owners** can turn SSO on, open the setup portal, or switch a policy on — whoever does those decides which identity provider vouches for every member. Turning anything **on** also needs the single sign-on add-on. Every route answers with the same `sso` status: whether SSO is on, `ssoRequired`, `jitJoinEnabled`, the seat limit, whether the add-on is held, and — once on — the connections and domains WorkOS reports (`null`, with `providerReachable: false`, if WorkOS cannot be reached) and `membersOutsideVerifiedDomains`, how many members your identity provider cannot sign in. | Method | Path | Notes | |---|---|---| | `GET` | `/orgs/{orgSlug}/sso` | The status. | | `POST` | `/orgs/{orgSlug}/sso` | Owners. Turns SSO on: creates the organisation's account at WorkOS, with no domains — domains are added and verified in the setup portal. **201** the first time; **200** and nothing created once on. | | `POST` | `/orgs/{orgSlug}/sso/portal` | Owners, within ten minutes of signing in again (**403** `reauth_required` otherwise) — by password, passkey, Google, single sign-on or an emailed code. `{ link }` — a WorkOS setup-portal link, valid for a few minutes, that returns to the organisation's settings. **409** before SSO is on. Every link is audited. | | `PATCH` | `/orgs/{orgSlug}/sso` | Body `{ ssoRequired?: boolean, jitJoinEnabled?: boolean }`. Switching either on is for owners; switching off, for owners and admins. Requiring SSO needs an active connection and a verified domain (**409** otherwise), and takes effect **immediately**: from then on, members who did not sign in through your identity provider — and CLI devices approved from such a sign-in — get **403** `sso_required` for this organisation, and keep every other. The response's `nonSsoAccess` says how many sessions and CLI tokens that affected. Members who get `sso_required` sign in again through the organisation's single sign-on (the dashboard offers the link; the CLI says to run `xecret login` through it). Owners are always exempt, so a misconfigured identity provider cannot lock the organisation out; every time an owner signs in, approves a CLI device or accepts an invitation that way, the audit log says so. `jitJoinEnabled` lets anybody your identity provider signs in, at a verified domain, join as a viewer while seats last — never somebody you removed; off, they need an invitation. | ## Tokens | Method | Path | Notes | |---|---|---| | `GET` `POST` | `/orgs/{orgSlug}/tokens/service` | Body `{ name, projectSlug, environmentSlug, accessLevel?: read\|write, expiresAt?, ipAllowlist? }`. `read` by default; `admin` is unrepresentable. Returned **once**. | | `GET` | `/orgs/{orgSlug}/tokens/cli` | "Your devices" — your own CLI tokens only, revoked ones included. | | `DELETE` | `/orgs/{orgSlug}/tokens/{kind}/{tokenId}` | `kind` is `cli` or `service`. Your own CLI token: always. Anyone else's, or any service token: needs the revoke permission. | | `GET` | `/tokens/self` | Service-token introspection: the pinned organisation, project and environment, plus the token's name and level. Derived from the credential row alone — there is no parameter to lie in. | A created token's value appears in exactly one response and is never retrievable again. No listing function selects the stored hash. ## Audit | Method | Path | Notes | |---|---|---| | `GET` | `/orgs/{orgSlug}/audit` | Owners and admins. Filters: `actorId`, `action`, `projectSlug` (+ `environmentSlug`), `outcome`, `from`, `to`. Keyset pagination behind an opaque cursor. | The response includes the `window` actually scanned, because the range is clamped to 90 days and a caller that asked for more must be told it got less. ## Next - [Tokens](tokens.md) — obtaining and scoping a credential. - [The API overview](../api.md) — errors, CSRF, rate limits. - [The audit log](../security/audit-log.md) — what each event means. --- # Tokens and credentials Source: https://xecret-staging.playxoft.com/docs/api/tokens Summary: Sessions, CLI tokens and service tokens — what each can do, how they are scoped and stored, how to rotate them, and what happens when one leaks. Updated: 2026-08-16 Three ways to prove who you are. Choosing the right one is most of the security work. ## The three credentials | | Session | CLI token | Service token | |---|---|---|---| | **Prefix** | — (a cookie) | `xct_` | `xst_` | | **Created by** | signing in | `xecret login` | the dashboard | | **Acts as** | you | you | itself | | **Scope** | your whole account | one organisation | one org, project **and** environment | | **Stored** | browser cookie | OS keychain | your CI provider's secret store | | **Expires** | on sign-out or revoke | on `xecret logout` or revoke | optionally, at a date you set | | **Can mint credentials** | yes | no | no | | **Offline cache** | — | yes | no | ## Sessions What the dashboard uses. A `__Host-`prefixed cookie, plus a CSRF cookie whose value must be echoed in the `X-Xecret-Csrf` header on every mutation. You can see and revoke your active sessions under *Settings → Security*, and sign out everywhere at once. ### The PIN lock A session can be locked with a PIN separately from being signed out. An unlocked session lasts 8 hours; a locked one keeps you signed in but refuses anything sensitive with `session_locked`. This is what makes it safe to stay signed in on a laptop you leave on a desk. Setting, changing and resetting the PIN are on the same settings page; a reset emails a single-use link to your own address and still requires the session, so there is no way to use it to probe whether an account exists. ## CLI tokens Created by `xecret login`, through a browser consent flow with PKCE. The token is stored in your operating system's keychain, never in a dotfile. **A CLI token adds no authority.** It acts as you: your role, your grants, and nothing more. That is why creating one requires only active membership rather than the permission to create *service* tokens — it hands out no power the holder did not already have. Consequences worth knowing: - Change somebody's role, and every device they own changes with it, at once. - Remove them from the organisation, and every device stops working, at once. - A CLI token cannot mint another credential. It cannot create a service token, invite anybody, or create an organisation. ### Managing devices *Tokens → Your devices* lists your own CLI tokens by the device name you gave at login, including recently revoked ones so a revocation you just performed is visible. Revoke your own from there or with `xecret logout`. An admin can revoke anyone's without browsing their device names first. Give devices names you will recognise. "MacBook" is useless when you have had three. ## Service tokens For CI, containers and anything else with no human at the keyboard. *Tokens → New service token*, then: | Field | Notes | |---|---| | Name | Appears in the audit log against every action the token takes | | Project + environment | The pin. Cannot be widened later. | | Access level | `read` (default) or `write`. `admin` is not representable. | | Expiry | Optional, and worth setting | | IP allowlist | Optional; use it if your runners have stable egress addresses | The value is shown **once**. Only its hash is stored, so there is no listing that could return it and no support process that can recover it. Lost means mint a new one. ### What a service token cannot do - **Delete a secret.** Ever, at any access level. CI rotates a value by writing a new version; destroying history is a human's decision. - **Act as a person.** Writes are attributed to the token itself, in a separate column from the one that names a user, with a database constraint requiring exactly one of the two. A CI write is never recorded under the name of whoever minted the token. - **Mint another credential.** No token can. - **Leave its pin.** Passing `--environment production` to a job whose token is pinned to `staging` fails with a 404, server-side. - **See the organisation list.** `GET /api/orgs` is refused: a service token is pinned to one organisation and has no switcher. ### Introspection A token can ask what it is: ```bash curl https://xecret.playxoft.com/api/tokens/self \ -H "Authorization: Bearer $XECRET_TOKEN" ``` ```json { "token": { "name": "ci-build", "accessLevel": "read" }, "organization": { "slug": "acme", "name": "Acme" }, "project": { "slug": "checkout-api", "name": "Checkout API" }, "environment": { "slug": "production", "name": "Production" } } ``` The answer derives from the credential row alone — there is no parameter to lie in. This is how `XECRET_TOKEN=… xecret run` learns its scope with no configuration file. ## How to scope them One token per *(project, environment, purpose)*. It is more tokens, and it is the right number: ```text acme/checkout-api/production read "deploy-production" acme/checkout-api/staging read "deploy-staging" acme/checkout-api/test read "ci-test" acme/marketing-site/production read "deploy-marketing" ``` The reason is blast radius. When one leaks, you want the answer to "what could it reach?" to be one environment of one project — and the answer to "who was using it?" to be one pipeline you can fix without breaking four others. Use `write` only where a job genuinely writes secrets, which is rarer than it first seems. A deploy reads. ## Rotation ```text 1. Mint a new token with the same scope. 2. Update the consumer (CI variable, orchestrator secret, container env). 3. Revoke the old one. ``` In that order. Revocation is immediate — the lookup filters on "not revoked" in SQL, so an in-flight job on the old token fails on its next request. Rotate on a schedule, and always when somebody with access to the CI configuration leaves. ## If one leaks 1. **Revoke it.** *Tokens → Service tokens → Revoke*. It stops working at once. 2. **Read the audit log** for that token's name: every read it performed, with timestamps. This is what tells you which secrets to treat as compromised. 3. **Rotate those secrets** at their source — the database password, the API key at the provider. Revoking the xecret token does not un-leak a value it already read. 4. **Mint a replacement** and fix whatever exposed the first one. Step 3 is the one people skip. A token that read your Stripe key has already given away your Stripe key. ## Next - [Secrets in CI](../guides/ci.md) — tokens in practice. - [The audit log](../security/audit-log.md) — reading what a token did. - [Endpoint reference](reference.md) — the token routes themselves. --- # What xecret can and cannot see Source: https://xecret-staging.playxoft.com/docs/security/trust-model Summary: The encryption model stated plainly — envelope encryption, the key hierarchy, what a database breach yields, and when you should choose a zero-knowledge product instead. Updated: 2026-10-21 A security product that overstates its guarantees has broken the only thing it sells. So this page says what xecret protects you from, and what it does not, before you put a real credential in it. ## The short version **xecret uses server-side envelope encryption. The service can technically decrypt your secrets.** This is the same model Doppler uses. It is what makes team sharing, CI tokens and browser-side import work without every user performing a key exchange. If you need a provider that *cannot* read your secrets even in principle, you need a zero-knowledge product, and you should know that now rather than after migrating. ## The key hierarchy ```text Root key ──wraps──▶ Organisation key ──wraps──▶ Environment key ──encrypts──▶ Secret version ``` Four layers, each versioned so any one can be rotated independently. | Layer | Where it lives | |---|---| | Root key | Cloudflare Secrets Store, bound to the Worker. **Never in the database.** | | Organisation key | In the database, encrypted under the root key | | Environment key | In the database, encrypted under its organisation's key | | Secret version | In the database, encrypted under its environment's key | Values are encrypted with **AES-256-GCM**, and each ciphertext is bound to its exact position in the hierarchy. A secret row moved from `staging` to `production` does not decrypt: the authentication tag covers the identity of the environment it belongs to. The same technique protects the CLI's [offline cache](../cli/offline-cache.md). ## What each kind of compromise yields | An attacker who gets… | Gets | |---|---| | A database backup | **Ciphertext only.** No key material at any layer is stored in the database. | | The database *and* the root key | Everything. This is the scenario the model does not defend against. | | Your CLI token | What you can reach, until it is revoked — and every use is audited. | | A CI service token | One environment of one project, read-only unless you made it otherwise. | | Your session cookie | Your account, subject to the PIN lock on anything sensitive. | The design goal is that the database and the key never sit in the same place: the database is a managed PostgreSQL instance, and the root key is in a Cloudflare Secrets Store binding that the database has no access to. ## The rules the implementation follows These are enforced structurally, not by convention: - **Decryption happens in one place.** The masked listing and the reveal endpoint are separate routes, so "where can a plaintext be produced?" has an answer a reviewer can verify by searching the code. - **Nothing is decrypted in bulk for a page render.** A dashboard listing shows names, versions and timestamps; values are fetched one at a time, on demand, and each fetch is audited. - **Every decryption produces an audit event.** An audit row therefore always means a plaintext actually left the server. - **Every denial is audited too.** A system that records only what succeeded cannot detect an attack in progress. - **Audit metadata cannot contain a value.** The type describing it is a fixed allowlist of field names with no catch-all, so a secret cannot be placed in a record — the compiler rejects it rather than a reviewer having to notice. - **Error messages are fixed strings.** Nothing derived from an exception, a database error, or the rejected input reaches a client, because the rejected input may itself be a secret. - **The database role is least-privilege.** The application's credentials cannot alter the audit table, which is append-only by grant rather than by discipline. ## What xecret does not protect you from Stated plainly, because these are the failures people are surprised by: - **A compromised laptop.** If somebody has your machine and your unlocked keychain, they have what you have. Revoke the device; the audit log tells you what it read. - **Your own code leaking values.** `xecret run` puts secrets into your process's environment. If your app logs `process.env`, or a crash reporter ships it, xecret cannot stop that. - **`xecret pull` writing a file.** The command warns each time. What happens to that file afterwards is yours. - **A member determined to keep a copy.** [Disabling plaintext export](../features/policies.md#disabling-plaintext-export) and the secret policy's value rules are enforced by the clients that hold the plaintext, so they stop honest mistakes, not somebody who means to get round them — anyone who may read a value can write it down. - **A server that misbehaves, in two narrow ways**, even where values are end-to-end encrypted. It could answer a [reference](../features/references.md) or an inherited name with a *different value you can already decrypt* — another secret, or one from another environment whose key you hold — because which environment a slug means, and which stored secret a name means, are things your client learns from the server. And it could keep serving a [personal override](../features/overrides.md) you replaced or removed, because an override carries no version. It cannot make up a value, read one you could not, or forge an override in your name. - **Anything a browser can read.** `NEXT_PUBLIC_` and `VITE_` variables are inlined into bundles you serve to the public. Managed and versioned, yes; private, no. See [React / Vite](../guides/react-vite.md). - **Us.** We can decrypt. That is this page's first sentence. ## If you self-host Self-hosting does not change the model — it changes **who** you are trusting from us to your own Cloudflare account, your own database, and your own key custody. That is a real improvement if you can operate key custody well, and a real downgrade if you cannot. The root key is the whole ballgame: > **Important** — Lose the root key and every secret is permanently > unrecoverable. There is no support ticket that fixes it. Generate it, split > it, and escrow the shares in physically separate places **before** the first > real secret is stored. See [self-hosting](../self-hosting.md). ## Choosing something else You should pick a zero-knowledge product instead if: - a regulator or contract requires that the provider be technically incapable of reading your data; or - your threat model includes the provider being compelled to hand over plaintext; or - you are storing secrets whose exposure would be unrecoverable regardless of rotation. You are fine here if: - your main problem is `.env` files sprawling across laptops and Slack; - you need CI, teams and rotation to be simple; and - you are willing to trust an operator with credentials you can rotate. We would rather write that paragraph than have you discover it later. ## Reading the source The server is AGPL-3.0 and the CLI is MIT. The cryptography and authorisation logic live in a package that imports nothing runtime-specific, so both can be audited and unit-tested in isolation rather than read through a web framework. ## Next - [The audit log](audit-log.md) — what is recorded and how to read it. - [Tokens](../api/tokens.md) — scoping credentials, and what to do if one leaks. - [Self-hosting](../self-hosting.md) — moving the trust to yourself. --- # The audit log Source: https://xecret-staging.playxoft.com/docs/security/audit-log Summary: Every event xecret records, what each one means, how to filter the log, and how to use it during an incident when a credential has leaked. Updated: 2026-10-20 Every mutation, every decryption, and **every denial** is recorded. A system that records only what succeeded cannot detect an attack in progress. Owners and admins can read the log. It is append-only, enforced by database grants rather than by convention — the application's own credentials cannot alter it. ## What a record contains | Field | Meaning | |---|---| | **Actor** | A person, a CLI token, a service token, or the system | | **Action** | What was attempted — see the table below | | **Outcome** | `success`, `denied`, or `error` | | **Resource** | The organisation, project, environment or secret involved | | **When** | Timestamp | | **Reason** | Extra context — a version number, a previous value of a setting | There is no field that could hold a secret value. The type describing the metadata is a fixed allowlist of field names with no catch-all, so a value cannot be placed in a record: the compiler rejects it rather than a reviewer having to spot it. ### Attribution is precise A write made by CI is recorded as the act of **that service token**, by name — never as the act of whoever minted it. Two separate columns hold the two kinds of actor, with a database constraint requiring exactly one of them to be set. This matters in the one log a company reaches for during an incident: putting a person's name on a write they did not make is worse than useless. ## The events ### Authentication | Action | Recorded when | |---|---| | `auth.login` | Somebody signed in. When the sign-in was held back for them to confirm first, `confirmation` says why: `organization` (it came back through an organisation the sign-in did not ask for), `not-member` (through the single sign-on of an organisation the account was not yet in) or `reused-session` (answered from an earlier sign-in at WorkOS, for an account other than the last one on that browser) | | `auth.signup` | A sign-in created a new account — recorded instead of `auth.login` for that one sign-in, so "when did this person join" is a filter. Carries `confirmation` the same way | | `auth.sign_in_declined` | A sign-in held back for confirmation was declined — "This isn't me" — and nothing was signed in. `confirmation` says why it was held back, `signInMethod` how the person was signed in. Recorded in the organisation whose single sign-on it came through when that organisation is on xecret, otherwise in the account's own. One is somebody choosing the wrong account; a run of them for one account is somebody trying to sign people in to theirs | | `auth.identity_linked` | A sign-in adopted an existing account by its verified email address and linked the sign-in identity to it — the one sign-in that changes which identity an account answers to. Written at the moment the link is made, even if that sign-in is then refused (single sign-on required, or no seat free); a linked sign-in that completes records `auth.login` as well | | `auth.sso_exemption_used` | An owner signed in without the organisation's required single sign-on, through the owner exemption — see *Single sign-on* below | | `auth.login_failed` | A sign-in was rejected | | `auth.logout` | Somebody signed out | | `auth.session_revoked` | A session was revoked | | `auth.locked` | A session was PIN-locked without being signed out | | `auth.pin_set` `auth.pin_changed` | The unlock PIN was created or replaced | | `auth.pin_reset` | The PIN was reset through an emailed link — somebody proved control of the mailbox rather than knowledge of the PIN | | `auth.autolock_changed` | The idle auto-lock interval changed | | `auth.account_deleted` | An account deleted itself. Terminal. | | `auth.reauthenticated` | The account proved itself again for a vault reset — signed in again, then entered the emailed code (and its authenticator code, if one is enrolled) — and was given the five-minute proof the reset spends. A reset that follows it was not a stolen session alone | ### Vault reset and recovery | Action | Recorded when | |---|---| | `vault.archive_emailed` | Before a reset, the encrypted archive of the vault was emailed to the account's address — with how many parts, environments and secrets, never which. Recorded as an error when no archive could be sent (no email set up, or the vault was too large) | | `vault.reset` | A vault was reset. The old one is kept for seven days, recoverable and undeletable | | `vault.recovered` | A reset vault's environment keys were brought back into the account's current vault, with its old passphrase or a recovery code. Recorded in each organisation that got keys back, with how many | | `vault.purged` | A reset vault's seven days ended and it was deleted. Recorded by the system, not a person | See [Resetting your vault](/docs/security/vault-archive) for what each of these means. The sign-in events — `auth.login`, `auth.signup` and `auth.identity_linked` — carry `signInMethod`: how the person authenticated, as one of a closed set: `password`, `oauth`, `sso`, `magic_auth` (a code sent by email), `passkey`, or `other` for a method the identity provider named that this list does not know. It is coarser than the provider's own vocabulary on purpose: a review asks whether this was a password, a Google account or the company's single sign-on, not which of many OAuth providers. ### Secrets | Action | Recorded when | |---|---| | `secret.created` | A new name appeared | | `secret.updated` | A new version was appended | | `secret.deleted` | A secret was soft-deleted | | `secret.rotated` | A value was rotated | | `secret.imported` | A bulk import ran | | `secret.read` | A **bulk** read — one row per `xecret run` or `pull`, with a count | | `secret.revealed` | A **single** value was decrypted and returned | `secret.read` and `secret.revealed` are the two rows that mean a plaintext left the server, and the distinction is deliberate. A 200-secret pull writes **one** `secret.read` row with a count, not 200 rows — otherwise the audit table becomes a denial-of-service surface against itself. `secret.revealed` is the one to watch. It means a person or token asked for one specific value, which is what curiosity and exfiltration both look like. A pull that reads beyond its own environment — values it [inherits, or a template refers to](../features/references.md) — writes a `secret.read` against **each** environment it read, naming the environment being pulled as context and, for a referenced secret, the secret that referred to it. Production's own log therefore shows a read of production that a template in staging caused. ### Organisations, projects, environments `org.created` · `org.updated` · `org.deleted` · `project.created` · `project.updated` · `project.deleted` · `environment.created` · `environment.updated` · `environment.deleted` | Action | Recorded when | |---|---| | `environment.parent_changed` | An environment started or stopped inheriting from another, naming both parents | | `environment.link_crosses_protection` | A person confirmed a [production environment reading from one that is not](../features/references.md#production-reading-from-elsewhere) — by inheriting from it (reason `inherited`) or through a template (reason `referenced`, naming the secret). Filed against production, naming the source; the inheritance change or the secret write is recorded beside it | | `environment.promoted` | A [promotion](../features/promotion-and-restore.md#promotion) landed, with counts and the source; each secret it wrote has its own `secret.created`, `secret.updated` or `secret.deleted`, with reason `promoted` | | `environment.restored` | A [point-in-time restore](../features/promotion-and-restore.md#point-in-time-restore) landed, with the instant and counts, and per-secret records with reason `restored`. Its preview is a `secret.read` with reason `restore-preview` | | `environment.policy_changed` | An environment's plaintext-export or read-alert switch changed | | `org.policy_changed` | The token policy or the secret policy changed | | `override.set` `override.removed` | A member set or removed one of their own [personal overrides](../features/overrides.md) — the name, never the value | | `webhook.created` `webhook.updated` `webhook.deleted` `webhook.secret_rotated` | A [webhook](../features/webhooks.md) endpoint changed, with its host and events — never its full URL, whose path may hold a credential | | `webhook.disabled` | An endpoint was switched off by the system after sustained failure | A refusal by one of your own policies is an `error` with reason `policyRefused` and the policy's name; a refusal by the plan is `quotaExceeded` with the feature or limit that refused it. ### Single sign-on | Action | Recorded when | |---|---| | `org.sso.enabled` | Single sign-on was turned on by an owner: the organisation's account at WorkOS was created and linked. Once only — of two owners enabling it at the same moment, only the one whose request linked it is recorded | | `org.sso.portal_link_created` | An owner who had just signed in again was handed a link to the WorkOS setup portal, where the identity provider is connected and domains verified. Every time — whoever opens one decides who can sign in as the organisation's members. The link itself is never recorded | | `org.sso.required_toggled` | "Require single sign-on" was switched on (by an owner) or off. `enabled` is the new value. It applies to every request from then on; owners are always exempt from it | | `org.sso.jit_toggled` | "Let people join through single sign-on" was switched on (by an owner) or off. `enabled` is the new value. Off, nobody joins by single sign-on alone, and no `member.jit_*` row is written | | `auth.sso_exemption_used` | An owner got past this organisation's required single sign-on some other way — a password, Google, another organisation's SSO — through the owner exemption, the break-glass that keeps a broken identity provider from locking the organisation out. Recorded when a credential is made that way: at sign-in, in **every** organisation whose requirement it bypassed; when such an owner approves a CLI device (it reaches the organisation the same way for as long as it lives); and when an owner's invitation is accepted that way. With `signInMethod` | A refused attempt at any of these is recorded too: `denied` for somebody without the role (an admin, for anything that switches something on), `error` for a missing add-on, a precondition (no active connection or verified domain, SSO not on yet, a link made in another WorkOS environment, a deployment whose WorkOS environment the database does not belong to, no recent sign-in before the setup portal) or WorkOS being unreachable. ### People and access | Action | Recorded when | |---|---| | `member.invited` | An invitation was sent | | `member.joined` | It was accepted | | `member.jit_joined` | Somebody signing in through an organisation's single sign-on was made a member on the spot, as a `viewer`. Nobody invited them: the organisation's identity provider vouched for an address in a domain the organisation verified, and the organisation had opted in to admitting people that way (`org.sso.jit_toggled`). Written only once the person confirmed the sign-in, which a first join by single sign-on always asks them to; any invitation still open for their address is withdrawn. Carries `signInMethod: sso` | | `member.jit_denied` | The same, refused. Outcome `error`, with a reason: `quotaExceeded` (`limitName` `seats`) when the organisation had no seat free — the sign-in fails — or `removed` for somebody the organisation removed and has not invited back, whose identity provider still vouched for them — they are signed in to whatever else they belong to, not back in here. Either way, the record of who was turned away | | `invitation.revoked` | It was withdrawn before acceptance | | `member.role_changed` | Somebody's role changed | | `member.suspended` `member.reinstated` | A membership was switched off, or back on | | `member.removed` | Somebody was removed. Their organisation's single sign-on does not bring them back, and any invitation still open for them is withdrawn with the removal; a new invitation does | | `member.custom_role_changed` | Somebody was put on a custom role, moved between two, or had theirs taken off — naming the role before and after | | `role.created` `role.updated` `role.deleted` | A custom role was defined, edited or deleted, with its whole definition — and, for an edit, the one it replaced and how many members held it. A `role.updated` **error** naming a member is an edit that was saved but whose follow-up for that holder — bringing their environment keys in line — did not finish; the environment's key page shows what they are still owed | | `access.granted` `access.revoked` | A grant was created or removed, with the previous and new levels | Suspension and removal are separate events because the histories imply different things. During a review, "could this person still act during the window?" has a different answer for each — a suspended member resolves to `none` everywhere while keeping their grants. ### Plans and billing Written by the system for what the payment provider, the seat sync and the billing jobs did, and by an owner for what they asked for. The actor of a system row says which: `Dodo webhook`, `seat sync`, `billing job` or `billing reconciler`. No row carries an amount, a card detail, or any identifier of the payment provider's — only plans, statuses, counts and the name of the event that caused it. | Action | Recorded when | |---|---| | `plan.changed` | What the organisation may do changed: a subscription started, changed plan or period, changed status, gained or lost seats, was cancelled for the period end, or ended. With the plan, interval, status and seats before and after, and the provider event (`billingEvent`) or the job's `reason` — a cancellation that reached its period end, or a subscription fourteen days on hold without a payment. An operator's change by hand carries `operator` | | `billing.checkout_started` | An owner was sent to checkout, for the plan, period and seat count recorded. Nothing is bought until the payment provider says so | | `billing.plan_change_requested` | An owner asked for a plan or period change — `takes effect now` or `takes effect at renewal` | | `billing.portal_opened` | An owner was handed a link to the payment provider's customer portal. The link itself is never recorded | | `billing.seats_synced` | The seat count billed followed the membership — or, as an `error`, could not reach the payment provider and is being retried | | `billing.payment_failed` | A payment was declined | | `billing.refunded` `billing.dispute_opened` | Money moved back, or a chargeback was opened. Access is unchanged by either | | `billing.usage_reported` | Metered secret fetches past the allowance were reported, with `unitCount` (a unit is a thousand fetches) | | `billing.region_mismatch` | The payment provider reported a billing country or currency other than the one the organisation is priced in, or a card from another country. Outcome `error`; nothing changed | | `billing.duplicate_subscription` | A subscription or a checkout payment was not applied: a second one beside the one the organisation has (or is still waiting to activate), one bought over a plan set by hand, or one that did not come from a checkout of this organisation's. Outcome `error` | | `billing.drift_detected` | The nightly comparison found our record and the payment provider's disagreeing — `driftFields` names what (`seats` includes the membership no longer matching what is billed). Nothing is corrected automatically | ### Credentials | Action | Recorded when | |---|---| | `token.authorized` | A CLI login was approved on the consent screen | | `token.created` | A token was minted | | `token.used` | A token authenticated a request | | `token.revoked` | A token was revoked — written by the call that actually did it, so a repeat revoke adds no noise | | `key.rotated` | An encryption key was rotated | ### Denials | Action | Recorded when | |---|---| | `access.denied` | Any attempt that was refused | Plus: any of the actions above can carry `outcome: denied`. Filtering on outcome rather than on this single action is usually what you want. ## Reading it *Audit* in the dashboard, or `GET /api/orgs/{orgSlug}/audit`. Filters: actor, action, project (and environment within it), outcome, and a date range. Results are paginated with an opaque cursor. The range is clamped to **90 days**, and the response states the window it actually scanned — a view showing less than it was asked for must say so rather than quietly returning a shorter answer. ```bash curl "https://xecret.playxoft.com/api/orgs/acme/audit?action=secret.revealed&from=2026-08-01" \ -H "Authorization: Bearer $XCT_TOKEN" ``` ## Using it during an incident A laptop is stolen, or a CI token leaks. The sequence: 1. **Revoke first.** The device under *Tokens → Your devices*, or the service token under *Tokens → Service tokens*. Revocation is immediate, and is never softened by the CLI's offline cache — a rejection is a decision, and decisions are not overridden by a local file. 2. **Find what it did.** Filter the log by that actor. `secret.read` rows tell you which environments were pulled and when; `secret.revealed` rows name individual secrets. 3. **Rotate what it touched**, at the source. Change the database password at the database, the API key at the provider, then write the new value into xecret. Revoking the token does not un-leak a value it already read — this is the step people skip. 4. **Check for denials.** A run of `outcome: denied` around the same time is somebody probing for what else the credential could reach. 5. **Write down what you found.** Filter by date range and export before the 90-day window moves. ## What to review routinely Worth a look once a month, or after anyone leaves: - `access.granted` on production environments — is each still needed? - `token.created` — is every standing service token still in use? - `secret.revealed` — is anybody reading production values by hand who should be using `xecret run`? - `member.role_changed` — did every promotion have a reason? - `role.updated` and `member.custom_role_changed` — did a custom role widen, or come off somebody, and was that meant? ## Next - [Trust model](trust-model.md) — what the log is protecting. - [Tokens](../api/tokens.md) — revocation and rotation. - [Teams and access](../guides/teams.md) — the grants these events describe. --- # Resetting your vault, the archive, and the seven-day window Source: https://xecret-staging.playxoft.com/docs/security/vault-archive Summary: What happens when you reset a vault you cannot open — the emailed archive and its risk, the seven days to recover, and how to import or open the archive offline. Updated: 2026-10-14 Your secrets are encrypted with keys only your master passphrase and your recovery codes can open. Nobody at xecret holds a copy. That is the point of the product — and it means that if you lose the passphrase **and** every recovery code, nobody can open your vault for you. Resetting is the way out of that dead end. It gives you a new, empty vault so you can keep using your account. This page says exactly what a reset does with the old one. ## What a reset does You start a reset from the unlock screen: **Forgot your passphrase?**, then **I have lost all five codes**. You sign in again, then enter a code we email you. Then: 1. **We email you an archive** of your secrets, still encrypted (see below). 2. **We reset your vault.** You set up a new one straight away, with a new passphrase and a new Emergency Kit. 3. **We keep your old vault for seven days.** If your passphrase or a recovery code turns up in that time, you can recover it (see below). Nothing in xecret can delete it before the seven days are up. After that it is deleted automatically, for good. Your teammates and your organisation's data are not affected. Until you recover the old vault, an owner or admin will need to share each team environment with you again; the dashboard asks them to. **We never reset a vault without its archive unless you choose to.** - If we cannot send the archive, nothing is reset. To try again you will need to sign in again and get a new code. - If xecret cannot send email at all, it does not reset vaults until it can. - If your vault is too large to email — more than about 8 MB of encrypted secrets, which is tens of thousands of them — the reset screen says so **before** you sign in again or confirm anything. You can still reset by ticking **Reset without the emailed archive**; we then email you a notice instead of an archive, and the seven days below are the only way back to the old secrets. ## The archive, and the risk that comes with it The archive is a `.zip` file attached to an email sent to your account's address. It holds every secret in the end-to-end encrypted environments your old vault could open — **still encrypted**, exactly as xecret stored them — plus the organisation, project, environment and secret **names**, in plain text. A large vault arrives as two emails, each with one part; each part opens on its own. Only the old vault's master passphrase, or one of its unused recovery codes, can open it. > **Anyone who gets that email or the attachment can try to guess your old > passphrase on their own computers, as fast and for as long as they like.** In > xecret, a wrong passphrase is slowed down and then locked out. Against a copy of > the archive there is no limit, no lockout, and no way to take the copy back. > Each guess is deliberately expensive (Argon2id, 64 MiB of memory per guess), so > a strong passphrase holds — but a weak one will not. So: - save the attachment somewhere private — a password manager or an encrypted disk — and **delete the email**, including from your bin; - do not forward it, and be aware that mailbox backups keep copies; - if you believe somebody else has had the email, assume anything the old passphrase protected may be exposed: the archive holds each environment's **key** as well as its values, so **rotate the environment keys** (an owner can, from each environment's key settings) and rotate the secrets themselves. Environments that are not end-to-end encrypted are not in the archive. A reset does not affect them: you can still read them after setting up your new vault. ## Recovering your old vault within seven days If you find your old passphrase or one of its recovery codes within seven days: 1. Set up your new vault (if you have not already) and unlock it. 2. Go to **Settings → Security → Recover a previous vault**. 3. Choose **Recover**, and enter the old passphrase or a recovery code. Your browser opens the old vault and moves its environment keys into your **current** vault. Your current passphrase, recovery codes and passkeys stay as they are — recovering brings back access to your environments, not your old passphrase. Anything you or your teammates did after the reset is kept: - an environment a teammate already shared with you again is left as it is; - environments you have lost access to since the reset are not restored; - environments deleted since the reset are skipped. The old passphrase and recovery codes are checked in your browser and are never sent to xecret. ### If somebody else reset your vault A reset needs a fresh sign-in **and** a code sent to your inbox, so somebody would need both. If you get a reset email you did not ask for: 1. Sign in and secure your account — change how you sign in, and sign out your other sessions. 2. If the vault you now find is not yours (somebody set up their own after the reset), reset again and set up your own vault. Your original vault is not affected by the second reset: each reset keeps its own old vault for its own seven days. 3. Recover your original vault from **Settings → Security → Recover a previous vault** with your passphrase. Somebody who held your account in between may have spent its wrong-guess allowance on purpose, so recovering can be refused for a while — **up to an hour** — before it works. The seven days are not shortened by it. 4. Treat the archive that email carried as exposed, and rotate environment keys as described above. ## Importing the archive into a project Any time — including long after the seven days — you can put the archive's secrets back into a project: 1. Open the project and choose **Import archive**. 2. Choose the `.zip` file (or the `archive.json` inside it). 3. Enter the **old** vault's passphrase or one of its recovery codes. The archive is opened in your browser; neither the file's contents nor the passphrase is sent to xecret. 4. For each environment in the archive, choose which environment of this project it goes into, or **Don't import**. Each of this project's environments is shown with its encryption mode. An archive environment starts matched only to the environment it came from — the same environment, not just the same name — and only when your vault can open that environment's key now. Whether you may write to it is checked when you import. 5. Choose what happens when a secret with the same name already exists: keep the existing value, or replace it with the archive's. 6. **Preview**. The preview lists every secret, with each value hidden until you reveal it, and marks the ones that would not be written — a name that already exists, when you chose to keep the existing value. **Import** is available only after it. Each value is encrypted in your browser with the target environment's current key before it is sent, so xecret never sees it. That is why only end-to-end encrypted environments are offered: a server-encrypted environment would receive the values readable, so it is shown but cannot be chosen. You need your vault unlocked, and permission to write to each environment you import into. Only values are imported — notes and value types are not. If you were the only member of an environment, its key was in the vault you reset, and nobody else holds it. The import says so — "Nobody can open this environment's key any more" — and the way through is to **create a new environment and import into it**. (Recovering the old vault within seven days brings the key back instead.) ## Opening the archive without xecret The archive does not need xecret, an account, or a network connection. With the [xecret CLI](/docs/cli/commands): ```bash xecret vault open-archive xecret-vault-archive-2026-10-12.zip ``` It asks for the old passphrase without showing it, then prints every secret, grouped by environment, as `.env` lines. Other options: | Option | What it does | |---|---| | `--recovery-code` | Ask for a recovery code instead of the passphrase | | `--format json` | Any export format: `env`, `json`, `yaml`, `shell`, `docker` | | `--environment acme/api/production` | Only one environment (or just `production`) | | `--out-dir secrets/` | Write one file per environment (mode 0600) instead of printing | | `--list` | Show what the archive holds, without opening it | The file format is specified in full in section 14 of xecret's E2EE format specification (`docs/security/e2ee-crypto-spec.md` in the [source code](https://github.com/playxoft/xecret/tree/main/docs/security)), so it can be opened by other tools too. Some values may not open: a value written under a key your account never held is reported as such, and a damaged value is reported and makes the command exit with an error. Everything else is still printed. ## What is recorded Your organisation's [audit log](/docs/security/audit-log) records `vault.archive_emailed` and `vault.reset` when you reset, `vault.recovered` when you recover (in each organisation whose environments came back), and `vault.purged` when the old vault is deleted after seven days. None of them contains a secret, a name of a secret, or anything from the archive. --- # Self-hosting xecret Source: https://xecret-staging.playxoft.com/docs/self-hosting Summary: Run xecret yourself — the honest dependency list, the root-key ceremony, a step-by-step deployment, and what you are signing up to operate. Updated: 2026-10-14 xecret is AGPL-3.0 and designed to be run by people other than us. This page is the honest version of what that takes, including the parts that are friction, because a secrets manager that hides its operational costs is not being honest about the one thing it sells. ## The dependency list, stated plainly | Dependency | Role | Required? | |---|---|---| | **Cloudflare Workers** (paid plan) | Runs the web app and API. The paid plan's CPU limits are assumed. | Yes | | **PostgreSQL** (Neon recommended) | All data. Hyperdrive in front of it on Cloudflare. | Yes | | **Cloudflare Secrets Store** | Holds the runtime copy of the root key, bound to the Worker. | Yes | | **A WorkOS account** | Identity only — AuthKit's hosted pages handle password, Google, MFA and enterprise single sign-on (though single sign-on connections cannot be set up from xecret yet). xecret checks the result once, server to server, and issues its own sessions. | Yes, and **this is real friction**. AuthKit is free up to a million monthly active users; enterprise SSO connections are billed by WorkOS. An identity-provider interface exists so another provider can be contributed. | | **A mail provider** | PIN-reset links, invitation emails, and the archive emailed before every vault reset. | **Yes, in production** — a production deployment without one resets no vault, because a vault is never reset without its archive being emailed first; the reset screen says so. Elsewhere optional: invitations return a shareable link instead, PIN reset answers `sent: false` with a reason, and a vault resets only when the person ticks "Reset without the emailed archive". | | **A deploy-time secret source** | Where *your* copy of the root key lives between deploys. The Worker never calls it at runtime. | Your choice — anything that can populate environment variables at deploy time. | If that list looks like a lot for a tool that injects environment variables: it is, and it is stated up front for that reason. ## Before anything else: the root key The root key encrypts every organisation's key, which encrypts every environment's key, which encrypts every secret. > **Important** — If you lose the root key, every secret is permanently > unrecoverable. There is no support ticket, no recovery mode and no vendor > that can help, including us. Do this step before storing the first real > credential, not after. 1. **Generate and split it.** ```bash npm run keygen # generates the key plus 2-of-3 Shamir shares ``` 2. **Escrow the shares in physically separate places.** A USB stick in a safe, a printed sheet in a different building, a second site. Never all digital, never all in one place, never all held by one person. 3. **Write down who holds what, and test a restore.** A key-recovery drill that has never been run is a key-recovery plan you do not have. Run it quarterly. ## Deployment ### 1. Database Create a PostgreSQL database and the restricted application role, then run migrations. ```bash npm ci npm run db:migrate # reads DATABASE_URL / MIGRATION_DATABASE_URL ``` Two roles, deliberately: migrations run as a role that can change the schema, and the application runs as one that cannot — and in particular cannot alter the audit table, which is what makes "append-only" a grant rather than a promise. ### 2. Identity: a WorkOS account xecret sends people to WorkOS AuthKit's hosted pages to sign in, checks the result exactly once, server to server, and from then on uses its own session. No identity-provider code runs in the browser, and nothing about the provider is needed when the app is built. Set it up in the WorkOS dashboard: 1. **Pick the environment.** A WorkOS account has a **Staging** and a **Production** environment, and they share nothing — not users, not keys. Use Staging for local development and staging deployments, Production for production. Every value below must come from the environment the deployment signs people in against; a key from one and a client id from the other fails every sign-in in a way that looks like a bug and is not. 2. **Register the redirect URI**, exactly `/api/auth/callback` — for example `https://secrets.your-company.com/api/auth/callback`, and `http://localhost:3030/api/auth/callback` in Staging for local development. xecret derives it from `XECRET_PUBLIC_URL`, so there is nothing to configure on the xecret side; a mismatch with the dashboard is a redirect AuthKit refuses. 3. **Set the sign-in endpoint** to `/api/auth/login`. When AuthKit needs xecret to *start* a sign-in rather than finish one, it sends the browser there. A sign-in xecret did not start is refused as expired. 4. **Enable the sign-in methods** — email and password, and Google. Staging can use WorkOS's demonstration Google credentials; **Production needs your own** Google OAuth client, created in the Google Cloud Console with the redirect URI WorkOS shows you, and its client id and secret entered in WorkOS. MFA is a WorkOS setting too, and recommended for organisation owners (see the warning below); with an authenticator app enrolled, resetting a vault also asks for its code. **Enterprise single sign-on cannot be set up from xecret yet**: the organisation settings that create the WorkOS organisation, hand a customer's IT admin the setup link, and make single sign-on required are not built. 5. **Enable Magic Auth**, and leave WorkOS's default Magic Auth email on. Resetting a vault asks for a one-time code that WorkOS creates and emails, and WorkOS refuses to create one while Magic Auth is disabled — so with it off, nobody can reset a vault. Enabling it also offers "email me a code" as a way to sign in; that never counts as the reset's first check, which counts only a password, a passkey, Google or single sign-on — no other OAuth provider. 6. **Collect three values.** The client id (`client_…`); the environment's secret API key; and a cookie password you generate yourself, at least 32 random characters: ```bash openssl rand -base64 32 ``` The cookie password signs the short-lived cookie that ties a sign-in to the browser that started it. It must not be the API key. Do not judge which environment an API key belongs to by its prefix — check where you copied it from. > **Warning** — Keep each environment's database to itself. Never point a > deployment that signs in through Staging — local development included — at a > database whose accounts sign in through Production. The first sign-in there > with an existing person's address links their account to a Staging identity, > and from then on their real sign-in is refused until somebody repairs the row > by hand. If you cannot be sure, test sign-in locally only with a brand-new > address. > **Warning** — Know what the vault reset's two checks do not stop. The first > asks AuthKit to demand the credential again, which WorkOS documents only for > its own sign-in: it does not make Google or a single sign-on provider ask > again, so for those accounts a live session there passes it silently. > Somebody at an unlocked laptop with that session and the person's inbox open > passes both checks; without MFA, so does somebody who controls the inbox > alone, by resetting the password first. A reset is not only destructive: > whoever does it sets up a new vault on the account, can read its server-mode > environments, and teammates are asked to re-share the end-to-end-encrypted > ones. An authenticator app enrolled in WorkOS is the defence — with one, the > emailed code alone is not enough. That path is tested against WorkOS's > documented behaviour, but has not yet been exercised against a WorkOS > environment with MFA turned on. > **Important** — Email verification is load-bearing. The first time somebody > signs in, xecret adopts the existing account that holds the same *verified* > address, and refuses an unverified one. AuthKit verifies every address and, > at the time of writing, has no switch to stop it. If WorkOS ever adds one, it > must stay on in both environments. The client id is not a secret — it appears in every sign-in URL — and goes in `wrangler.toml` (step 4). The API key and the cookie password are secrets and go on the Worker: ```bash npx wrangler secret put WORKOS_API_KEY --env production npx wrangler secret put WORKOS_COOKIE_PASSWORD --env production ``` Put the client id in your deploy-time secret source as well. The config checker in step 5 reads it from there, and `npm run check:deploy` compares that copy with the one in `wrangler.toml`. Without any of the three, nobody can sign in, and the sign-in pages say so — the browser lands on the sign-in page's "unavailable" error — rather than guess. ### 3. Cloudflare Create: - a **Hyperdrive** binding for the database (pointed at the *direct* endpoint — Hyperdrive pools, so a pooling endpoint must not be used underneath it); - the **rate-limit** bindings; - a **Secrets Store** entry holding the root key. The repository's `wrangler.toml` names every binding the code expects, and `.env.example` names every variable. ### 4. Tell the deployment it is yours `apps/web/wrangler.toml` is committed to the repository, and everything account-specific in it is **ours** — the domain, and the ids of the resources step 3 just told you to create. Nothing you have done so far has changed any of it, and nothing later on this page checks it for you. Edit it now, before the first deploy: ```toml [env.production] name = "xecret-production" routes = [{ pattern = "secrets.your-company.com", custom_domain = true }] hyperdrive = [{ binding = "HYPERDRIVE", id = "" }] [env.production.vars] XECRET_ENV = "production" XECRET_PUBLIC_URL = "https://secrets.your-company.com" WORKOS_CLIENT_ID = "" # …the rest as shipped [[env.production.secrets_store_secrets]] binding = "XECRET_ROOT_KEYS" store_id = "" secret_name = "XECRET_ROOT_KEYS" ``` The ids are not credentials — they are useless to anyone without an API token for the account that owns them, which is why they are committed at all. But left as shipped, `wrangler deploy` authenticates as **you** and then addresses resources belonging to **us**, and the failure reads like a permissions problem rather than a copied config. The WorkOS client id is the same: left as shipped, every sign-in goes to our WorkOS environment, which knows nothing of your redirect URI and refuses it. If you also run a staging deployment or develop locally, put your **Staging** client id in `[env.staging.vars]` and the top-level `[vars]` the same way. `XECRET_PUBLIC_URL` is read twice, and both readings matter: - **During `next build`.** Every documentation and marketing page is prerendered, so its canonical URL, its `sitemap.xml` entry, the `Host` line in `robots.txt` and the JSON-LD `@id` are all written before the Worker exists. Left as shipped, your deployment tells every crawler that our site is the canonical version of your pages. - **At runtime, by the Worker.** It is what the `Origin` header on every mutation is compared against — deliberately, rather than the request's own `Host`, so that an attacker who can reach the Worker cannot choose the value the check uses. It is also the base of the links in invitation and PIN-reset emails, and of the sign-in redirect URI. Left as shipped, mutations from your own dashboard are rejected as cross-site, the invitation you send a colleague points at a server you do not control, and AuthKit is asked to send people back to a callback your WorkOS environment does not list. The two are kept identical by construction: the deploy script reads `XECRET_PUBLIC_URL` out of this file and hands it to the build, so there is one place to change and no second copy to forget. ### 5. Verify before deploying Both checks read the deployment's variables from their own environment, so run them under the environment you are about to deploy. With this repository's Phase.dev setup that means naming it, because `.phase.json` defaults to `Development`: ```bash phase run --env Production -- npm run check:env # states exactly what is missing or malformed phase run --env Production -- npm run smoke # proves the whole stack against the real database ``` Without Phase, export the same variables into the shell first — `set -a; . ./.env; set +a` — and then run `npm run check:env` and `npm run smoke`. Run both. The config checker catches the missing binding you will otherwise discover from a 503 in production. When the WorkOS values are set, the config checker also makes one network call to prove that the API key and the client id belong to the same WorkOS environment: it presents WorkOS with a deliberately bogus sign-in code, and WorkOS's answer says whether it accepted the pair and refused only the code (they match) or refused the pair (they do not). Nothing is inferred from the key's prefix. `npm run check:env -- --offline` skips the call. ### 6. Deploy ```bash phase run --env Production -- sh scripts/deploy-web.sh production ``` The `phase run --env Production --` prefix is how *this* repository populates the deploy-time environment, naming the same environment step 5 checked; substitute Doppler, a CI secret block, or `set -a; . ./.env`. The script does not care which. It reads `XECRET_PUBLIC_URL` and `XECRET_ENV` out of `wrangler.toml` for the environment you named, exports them into the build, and refuses to go on if that environment declares no `WORKOS_CLIENT_ID` — the Worker reads it at runtime and the build never does, so nothing else would notice it missing until every sign-in failed. Then it prints what it resolved before it starts: ``` Building for production at https://secrets.your-company.com (from apps/web/wrangler.toml → env.production.vars — stop now if that is not your deployment) (signing in against WorkOS client client_… — its API key must be from the same WorkOS environment) ``` Read those lines. If they do not name your domain and your WorkOS client, step 4 has not been done, and everything after this point publishes somebody else's. #### If you build without the script Both variables have to be in the **build** environment, not only in the Worker's bindings, for the reasons in step 4: ```bash XECRET_PUBLIC_URL=https://secrets.your-company.com \ XECRET_ENV=production \ npx opennextjs-cloudflare build --env production npx opennextjs-cloudflare deploy --env production ``` Set `XECRET_ENV` even though it looks redundant beside `--env production`. Two things make it load-bearing. `--env` puts nothing into the build's *environment* — it selects which environment's `compatibility_date` and `assets` settings the adapter reads, and none of that reaches `next build`. And the build only *refuses* to guess an origin when `XECRET_ENV` names a deployment: with `XECRET_ENV` unset it does not error at all, it quietly falls back to `http://localhost:3030` and writes that into the canonical URL of every page it prerenders. `XECRET_ENV` is what converts that silent wrong answer into a build failure, which is the only reason to type it. ### 7. Point clients at it ```bash xecret login --api-url https://secrets.your-company.com ``` The URL is stored with the credential. In CI, set `XECRET_API_URL` beside `XECRET_TOKEN`. See [configuration](cli/configuration.md). ## What you are now operating - **Backups.** The database holds only ciphertext; a backup without the root key escrow is a paperweight. Test restore of **both**. - **Migrations.** Generated SQL, committed and reviewed, applied with `npm run db:migrate`. Never auto-applied on deploy. - **The audit log.** Append-only and partitioned by quarter, with the child tables kept in the `audit_parts` schema. Migration 0010 pre-creates eight quarters, counting the current one, and nothing extends that automatically yet, so run this before the runway ends — once a quarter's rows land in the default partition, that quarter can no longer be given a real partition: ```sql SELECT create_audit_log_partition(d::date) FROM generate_series( date_trunc('quarter', now() AT TIME ZONE 'UTC'), date_trunc('quarter', (now() + interval '21 months') AT TIME ZONE 'UTC'), interval '3 months' ) AS d; ``` Run it as the owner of the audit tables — it issues `CREATE TABLE` and `GRANT`, and the application role holds neither. It fills every quarter to the end of the runway, so it is idempotent and any cadence shorter than 21 months is safe. If any quarter in the range already has rows in the default partition, the whole statement aborts and no quarter is created — recover those first, oldest first, then re-run it. Recovering one is a single transaction, as the table owner, substituting that quarter's UTC bounds. Detaching is what makes the `CREATE TABLE` possible; the `DELETE` is what makes the re-attach possible; and no audit row is destroyed, because every one is written back through the parent before it is removed: ```sql BEGIN; ALTER TABLE public.audit_logs DETACH PARTITION audit_parts.audit_logs_default; SELECT create_audit_log_partition(DATE '2028-07-01'); INSERT INTO public.audit_logs SELECT * FROM audit_parts.audit_logs_default WHERE created_at >= TIMESTAMPTZ '2028-07-01 00:00:00+00' AND created_at < TIMESTAMPTZ '2028-10-01 00:00:00+00'; DELETE FROM audit_parts.audit_logs_default WHERE created_at >= TIMESTAMPTZ '2028-07-01 00:00:00+00' AND created_at < TIMESTAMPTZ '2028-10-01 00:00:00+00'; ALTER TABLE public.audit_logs ATTACH PARTITION audit_parts.audit_logs_default DEFAULT; COMMIT; ``` Do it in a maintenance window: `DETACH` takes `ACCESS EXCLUSIVE` on `public.audit_logs` and holds it to `COMMIT`, so audit writes block throughout, and a blocked audit write fails the request that produced it. That covers the case you are likely to meet. Two rarer ones — a partition left detached by an interrupted repair, and a partition attached over the wrong range because it was created from a non-UTC session — need a wider procedure, in `docs/operations/database-setup.md` in the repository. The migration names that file in the error it raises for each. - **Mail, monitoring and error reporting.** Yours to wire. The log pipeline contains no secret values by construction, but where the logs go is your decision. - **The identity provider.** Keep each WorkOS environment's redirect URI in step with `XECRET_PUBLIC_URL`. Rotate the API key in the WorkOS dashboard, then with `wrangler secret put`; rotating the cookie password costs only the sign-ins in flight at that moment. Signing out of xecret does not end somebody's AuthKit session, so a returning person may not be asked for a credential. Resetting a vault asks AuthKit for a fresh sign-in, and the server checks the sign-in really happened during that request, but the check never reaches a Google or single sign-on session — see the warning in step 2. - **Key rotation.** Every layer of the hierarchy is versioned so it can be rotated independently. Have a runbook before you need one. - **Webhook egress.** A [webhook](features/webhooks.md) is a URL an organisation admin types and your deployment then fetches. xecret refuses `http`, credentials in the URL, local names and every private, loopback, link-local, reserved and IPv6 transition-mechanism address *literal*, before every attempt, and never follows a redirect. It does not resolve DNS, so a public name that points at a private address — or is re-pointed at one later (DNS rebinding) — gets through that check. On Cloudflare Workers the platform closes the gap: a Worker cannot reach a private network or a metadata endpoint. **Anywhere else it is open**, and the server would send requests into your own network. Put an egress control in front of it that judges the address after resolution — a forward proxy that refuses private destinations, or a network policy denying private, link-local and metadata egress — and until you have one, do not create webhook endpoints. ## The trust model you are accepting Self-hosting does not change the encryption model — it changes who you are trusting. Your deployment *can* decrypt the secrets it stores; that is what makes team sharing, CI tokens and browser import work. What moves is the trust: from us to your own Cloudflare account, your own database, and your own key custody. Read [what xecret can and cannot see](security/trust-model.md) before you accept other people's production credentials into a deployment you operate. ## Licensing The server is **AGPL-3.0**. The CLI is **MIT**. In plain terms: run xecret yourself freely. If you modify the server and offer it to others as a network service, publish your changes. The CLI is unencumbered so it can live inside your Docker images and CI pipelines without licence friction. ## Next - [Trust model](security/trust-model.md) — the model you are taking on. - [Configuration](cli/configuration.md) — pointing clients at your deployment. - [The audit log](security/audit-log.md) — what your deployment records. --- # Troubleshooting Source: https://xecret-staging.playxoft.com/docs/troubleshooting Summary: Every error message the CLI and API produce, what it actually means, and the fix — from "command not found" to "no such environment" in CI. Updated: 2026-08-16 Find your message. If it is not here, `xecret version` and the `requestId` from an API error are what a bug report needs. ## Installing and running the binary ### `xecret: command not found` The install directory is not on your `PATH`. Reopen your terminal first — an installer that just added a directory cannot change the shell it ran in. ```bash export PATH="$HOME/.local/bin:$PATH" # or /usr/local/bin ``` Add that line to `~/.zshrc` or `~/.bashrc` to make it stick. ### The install script fails on checksum verification The download did not match the published checksum. Do **not** work around it. Retry once — a truncated download is the usual cause. If it fails again, stop and report it; that is exactly the failure the check exists to catch. ## Signing in ### `not signed in` / `Run 'xecret login'` No credential on this machine, or it was revoked. Run `xecret login`. If you *did* just log in, check you are pointed at the right deployment: ```bash xecret whoami ``` `whoami` asks the server rather than reading the stored credential, so a revoked device reads as signed out here rather than confidently reporting a stale identity. ### The browser opens but nothing happens after approving The CLI listens on `127.0.0.1` on a random port for the redirect. Something is blocking it — usually a VPN with a local proxy, or a firewall rule on the loopback interface. The consent code is single-use with a 10-minute lifetime, so just run `xecret login` again after adjusting; nothing is left half-authorised. ### `login` says the keyring is unavailable No OS keyring on this machine — common on headless servers and minimal containers. The CLI falls back to a `0600` file under `~/.xecret/` and tells you so. To choose that explicitly: ```bash export XECRET_KEYRING=file ``` On a shared machine, prefer a service token over a stored login. ### `logout` is refused `XECRET_TOKEN` is set in your environment. Service tokens are revoked from the dashboard, not from the machine holding them. `unset XECRET_TOKEN` if you meant to log out of a stored login. ## Finding your project ### `no .xecret.yaml found` You are outside a configured project. Either: ```bash xecret init # create one here xecret run --project p --environment e -- cmd # or name the scope explicitly ``` The file is found by walking up from the current directory, so it works from any subdirectory — but not from a sibling directory of the repository. ### The wrong environment was loaded Check in this order: 1. Flags on the command — they beat everything. 2. `.xecret.yaml` in the nearest parent directory. In a monorepo, a package may have its own file shadowing the root one. 3. `XECRET_TOKEN`, whose pinned scope answers when nothing else does. ```bash xecret whoami # under a token, prints the pinned scope cat .xecret.yaml ``` ### `no project with slug "…"` The slug does not exist, or you cannot see it. `xecret projects` lists what you can reach. Remember that "not visible to you" and "does not exist" deliberately look identical — that is what stops the API being used to enumerate another company's projects. ## Running your app ### My app says a variable is undefined In order of likelihood: 1. **The secret is not in that environment.** `xecret secrets list` shows what is actually there. 2. **The wrong environment is loaded.** See above. 3. **The variable needs a prefix.** Vite only exposes `VITE_`-prefixed variables to your code; Next.js only exposes `NEXT_PUBLIC_` ones to the browser. 4. **The value is needed at build time, not run time.** `NEXT_PUBLIC_` and `VITE_` values are baked in during the build, so the *build* must run under `xecret run`. ### Changing a secret did not take effect The environment is injected when `xecret run` starts the process. Hot reload does not re-fetch. Stop the process and run the command again. ### `xecret run` hangs Almost always your own command waiting for something, since `run` execs it directly and forwards signals. `Ctrl-C` reaches the child. If it hangs *before* any output, the API call is timing out — see the offline section below. ## Network and offline ### `xecret API unreachable — using cached secrets` Working as designed. The API could not be reached, so the encrypted local cache answered, and the CLI said so on stderr with the cache's age. If the age is surprising, that is the point of printing it. `--no-cache` makes the command fail instead of falling back. ### It failed instead of falling back to the cache The cache answers only when the API *cannot* answer — a network failure or a 5xx. A 401, 403 or 404 is a **decision**, most importantly a revocation, and decisions are never softened by a local file. Also check there is a cache at all: the first run in a new environment has nothing to fall back to. ### Behind a corporate proxy The CLI honours the standard `HTTPS_PROXY` and `NO_PROXY` variables. ```bash export HTTPS_PROXY=http://proxy.internal:3128 ``` A TLS-intercepting proxy needs its CA in your system trust store; the binary uses the platform's roots. ## CI ### `XECRET_TOKEN was not accepted` Read the whole hint the CLI prints. In order: - **Copied wrong.** The token is one long line. A CI variable UI that wraps or trims it silently is a common cause — re-paste it. - **Expired.** Check the expiry in *Tokens → Service tokens*. - **Revoked.** Same page; revoked tokens are listed. - **IP allowlist.** If you set one, your runner's egress address must be in it. Hosted runners change addresses. ### `This service token cannot do that` The token is `read` and the job is writing, or the job is trying to **delete** a secret — which no service token can do, at any access level. Mint a `write` token for the first case; for the second, do it from the dashboard. ### `not found` for an environment that exists The token is pinned to a different project or environment. Passing `--environment production` to a job whose token is pinned to `staging` fails server-side, by design. ```bash curl https://xecret.playxoft.com/api/tokens/self \ -H "Authorization: Bearer $XECRET_TOKEN" ``` That prints the token's actual pin. ### Everything works locally and fails in CI Nine times out of ten: the CI job has no `.xecret.yaml` and you expected one, or the token's pin differs from the environment you use locally. Print `xecret whoami` as the first step of the job. ## Writing secrets ### `is not a valid secret name` Names follow shell rules: letters, digits and underscores, never starting with a digit, up to 256 characters. `MY_KEY` is valid; `my-key`, `MY.KEY` and `2FA_SECRET` are not. ### `value does not match the declared type` The secret has a declared value type and the new value does not satisfy it — this is the check catching `3o30` before your service does at 02:00. Fix the value, or change the type with `xecret secrets set NAME --type string`. ### `409 conflict` A name or slug is already taken, or two writes raced. Retry; if it persists, the name genuinely exists. ## Permissions ### `403 forbidden` You are a member, but not permitted. Usually production: developers and viewers get `none` there by default, and need an explicit grant. Check *Members → you → Access*, which shows your resolved level for every environment and the rule that produced it. ### `session_locked` Your session is signed in but PIN-locked. Enter the PIN. This is the dashboard only; bearer tokens are not PIN-gated. ### `409` when deleting your account You are the only active owner of an organisation other people are in. Ownership has to move first — promote somebody, then try again. ## Reporting a bug Include: ```bash xecret version ``` the exact command, the full error including the hint line, and the `requestId` from any API error. That id correlates with the server's logs, which never contain your values. ## Next - [FAQ](faq.md) — questions rather than errors. - [Command reference](cli/commands.md) — every flag. - [The offline cache](cli/offline-cache.md) — the rules behind the fallback. --- # Frequently asked questions Source: https://xecret-staging.playxoft.com/docs/faq Summary: Short answers about pricing, security, offline use, team access, migrating away, self-hosting and how xecret compares to .env files and other tools. Updated: 2026-10-12 Short answers, each linking to the page that gives the long one. ## Getting started ### Do I have to change my code? No. `xecret run` puts secrets into your process's environment, and your code keeps reading `process.env`, `os.Getenv` or whatever your language calls it. There is no SDK to install. ### What happens to my existing `.env` file? Import it, then delete it. The dashboard parses it in your browser and shows a preview before anything is sent. See [importing and exporting](guides/import-export.md). ### Can I still use `.env` alongside xecret? You can, and you will regret it. Two sources of truth for the same configuration is the problem xecret exists to remove. Import once and delete the file. ### Does it work on Windows? Yes — a native binary, with credentials in Windows Credential Manager. PowerShell, `cmd.exe` and WSL all work. See [installing the CLI](install.md). ## Security ### Can xecret read my secrets? Yes, technically. xecret uses server-side envelope encryption: values are decrypted inside a single request handler, and every decryption is audited. This is the same model Doppler uses, and it is what makes team sharing, CI tokens and browser import work. If you need a provider that cannot read your secrets even in principle, you need a zero-knowledge product. The full reasoning, including when to choose something else, is in [what xecret can and cannot see](security/trust-model.md). ### What happens if your database is breached? The attacker gets ciphertext. No key material is stored in the database at any layer — the root key lives in a separate Cloudflare Secrets Store binding the database has no access to. A breach of *both* is the scenario the model does not defend against. ### Are secrets ever written to my disk? Only if you ask for it. `xecret run` never writes them. `xecret pull` does, by design, and warns on stderr every time. The offline cache stores an encrypted copy whose key is in your OS keychain — see [the offline cache](cli/offline-cache.md). ### Can a teammate see my production secrets? Not by default. Developers and viewers get `none` on production; reaching it takes an explicit grant from somebody who manages members, and that grant is audited. See [teams, roles and access](guides/teams.md). ### Who can see that I read a secret? Owners and admins, in the [audit log](security/audit-log.md). Every decryption is recorded — including yours. ## Day to day ### Does xecret work offline? For `xecret run`, yes. An encrypted local cache answers when the API cannot be reached, printing its age to stderr. It does **not** answer when the server actively refuses — a revocation must take effect immediately. CI has no cache at all, deliberately. See [the offline cache](cli/offline-cache.md). ### Do I need to be online to start my app? Only for the first run in a given environment, which is what populates the cache. After that, a network outage falls back rather than failing. ### Why does my app not see a changed secret? The environment is injected when the process starts. Restart it — the same moment you would have edited `.env.local`. ### Can I run different environments at once? Yes: ```bash xecret run --environment staging -- npm run dev xecret run --environment production -- ./verify.sh ``` Each command gets its own environment; nothing is shared between them. ### Is `.xecret.yaml` safe to commit? Yes, and you should. It contains two slugs and no secrets — the type that models it has exactly two fields, so nothing in the CLI can write anything else into it. ## Teams and CI ### How many people can I add? As many as your plan allows — three on Free, counting the people you have invited and not yet seen accept; Pro and above do not limit members. The seat count is enforced when an invitation is sent and again when it is accepted, inside the same transaction as the membership. Every other plan limit works the same way and applies from the day it is published: adding one past it is refused, naming the plan that allows more, and nothing you already have is taken away. See [plan limits](api/reference.md#plan-limits). ### What is the difference between a CLI token and a service token? A CLI token acts as *you* — your role, your grants. A service token is not a person: it is pinned to one project and one environment, is read-only by default, and can never delete a secret. See [tokens](api/tokens.md). ### Can CI write secrets? Yes, with a `write` service token. It can never delete one. CI rotates a value by writing a new version; destroying history is a human's decision. ### What do I do if a token leaks? Revoke it, read the audit log to see what it read, then **rotate those secrets at their source**. Revoking the token does not un-leak a value it already read. The full sequence is in [tokens](api/tokens.md). ## Leaving and self-hosting ### Can I export everything? Yes. `xecret pull` in five formats, or the export dialog in the dashboard. A secret manager you cannot export from has taken your configuration hostage. Version history is not part of a bulk export. See [importing and exporting](guides/import-export.md). ### Can I run it myself? Yes — it is AGPL-3.0 and self-hosting is a documented first-class path. It needs Cloudflare Workers, PostgreSQL and a WorkOS account for identity. The honest dependency list is in [self-hosting](self-hosting.md). ### What licence is it under? The server is AGPL-3.0; the CLI is MIT. Run it yourself freely; if you modify the server and offer it as a network service, publish your changes. The CLI is unencumbered so it can ship inside your Docker images without licence friction. ### Is it production-ready? Not yet. xecret is **pre-alpha**: feature-complete for its first version, but it has not run against production infrastructure and has not had its security pass. Do not store credentials you cannot afford to rotate. ## Comparisons ### How is this different from a `.env` file? A `.env` file is a copy, and copies are what you stop tracking: who has one, whether it is current, and what was on the laptop that went missing. xecret stores the value once and injects it. See [what is xecret](what-is-xecret.md). ### How is this different from GitHub Actions secrets? Those solve CI and nothing else. Your laptop, your containers and your other CI provider are separate silos you keep in sync by hand. xecret is one store for all of them, and CI reads it through a scoped token. ### How is this different from AWS Secrets Manager? Cloud secret managers are built for infrastructure and usually need SDK calls in your application. xecret is built for `npm run dev` and injects into the process instead. They are complementary more often than competing. ## Next - [Troubleshooting](troubleshooting.md) — for error messages rather than questions. - [Quickstart](quickstart.md) — if you have not tried it yet. - [Trust model](security/trust-model.md) — the security answers in full. --- # Using xecret with AI agents Source: https://xecret-staging.playxoft.com/docs/ai-agents Summary: The one-line skill install for Claude, ChatGPT, Gemini and every other coding agent, the machine-readable documentation endpoints, and letting an agent run your app without production credentials. Updated: 2026-09-20 Three separate things share this page, because the people asking about one are usually about to need the next: 1. **Teaching an agent to use xecret** — one document, one line to install it. 2. **Reading this documentation programmatically** — the endpoints an agent should fetch. 3. **Letting an agent run your code** — without giving it production credentials. ## The skill A single document teaches any agent how this product works: the commands, the scoping rules, the CI story, the errors, and the conventions it is expected to respect — never print a value, prefer `xecret run` over a `.env` file, ask before touching production. It is published at `https://xecret.playxoft.com/skill.md` and is the same document however you load it. **In a repository**, for agents that read files — Claude Code, Codex, Cursor, Antigravity, Gemini CLI, Copilot, Windsurf, Zed: ```bash curl -fsSL https://xecret.playxoft.com/skill.sh | sh ``` That writes the skill to `.agents/skills/xecret/SKILL.md` and `.claude/skills/xecret/SKILL.md`, and adds a short pointer to your `AGENTS.md` so an agent that reads only that file still finds it. It installs at the root of the repository you are standing in, wherever in the tree you run it from, and refuses to run outside one; `--no-pointer` skips the instruction files, and `XECRET_SKILL_DIR` names a directory explicitly. Re-running it upgrades the copies and leaves your own instructions alone. Commit the result: everyone who clones the repository gets it, and the next person's agent does not have to be told twice. **In a chat**, for ChatGPT, Gemini, Claude or anything else with a browser: ```text Read https://xecret.playxoft.com/skill.md and follow it whenever I ask you about xecret, secrets, or environment variables. ``` **Without writing any files**, pipe it straight into whatever you are feeding: ```bash curl -fsSL https://xecret.playxoft.com/skill.sh | sh -s -- --print ``` Self-hosting? Every deployment serves its own copy, so point at yours and the agent learns your hostname along with everything else: ```bash XECRET_SKILL_URL=https://secrets.your-company.com/skill.md \ sh -c "$(curl -fsSL https://secrets.your-company.com/skill.sh)" ``` The skill is a summary with an escape hatch: it names the endpoints below, so an agent that needs the exact flags of one command fetches that page rather than guessing from what it remembers. ## Machine-readable documentation Every page is published twice: as HTML, and as the markdown it was written in. There is one source file; the HTML is generated from it at build time, so the two cannot disagree. | URL | What it returns | |---|---| | `/docs/.md` | One page as markdown. Append `.md` to any documentation URL. | | `/llms.txt` | An index: every page with a one-line summary and a link to its markdown. | | `/llms-full.txt` | Every page, concatenated in reading order, in one file. | ```bash curl https://xecret.playxoft.com/llms.txt curl https://xecret.playxoft.com/docs/cli/commands.md curl https://xecret.playxoft.com/llms-full.txt ``` Each page's markdown carries frontmatter with its title, a one-sentence description, keywords and the date it was last edited — enough to decide relevance without fetching the body. ### Which to fetch - **Answering one question:** fetch `/llms.txt`, pick the page whose description matches, fetch that one `.md`. - **Doing a whole task with xecret:** fetch `/llms-full.txt` once. It is the entire corpus and is smaller than several round trips. Each document in `llms-full.txt` is headed by its canonical URL, so anything quoted from it can be cited back to a page a person can open. ## Letting an agent run your app This is the part with a real decision in it. A coding agent that runs your test suite or starts your dev server needs the same environment your app needs. Handing it your production credentials because that is what was in your shell is how a debugging session becomes an incident. ### Give the agent its own environment Create an environment in the dashboard — call it `agent`, or reuse `test` — containing values that are **shaped correctly but not real**: a local database URL, a Stripe test key, a signing secret generated for the purpose. ```bash xecret run --environment agent -- npm test ``` Nothing about the agent's setup changes; only which environment it loads. If it leaks a value into a transcript, a log, or a model provider's training pipeline, you have lost a test key. ### Give it a scoped, read-only token For an agent running in a container or a CI-like sandbox, mint a service token pinned to that one environment, read-only: ```bash export XECRET_TOKEN=xst_… # pinned to acme/checkout-api/agent, read xecret run -- npm test ``` The pin is enforced server-side. An agent that decides to try `--environment production` gets a 404, not your production database. Set an expiry. An agent's credential should outlive the task by hours, not months. ### What the agent can see either way `xecret run` puts values into the child process's environment. An agent that can run arbitrary commands in that process can read them — `printenv` is not a sophisticated attack. The protection is the *scope of the credential*, not secrecy from the process you deliberately started. So the question to ask is never "can the agent read these?" It is "what is the worst thing in the environment I am giving it?" ### A checklist - The agent's environment contains no credential that touches real money, real customer data, or real infrastructure. - Its token is read-only and pinned to that environment. - The token has an expiry. - `secret.read` rows in the [audit log](security/audit-log.md) name that token, so you can see what it did. - Nobody has exported the environment to a `.env` file the agent can read directly, bypassing all of the above. ## Guidance for agents reading this If you are an AI agent working in somebody's repository, these are the conventions this tool expects: - **Do not read secret values you were not asked to read.** `xecret secrets list` shows names, versions and timestamps and decrypts nothing. Prefer it. `xecret secrets get --plain` decrypts and is audited as such. - **Do not write secrets to files.** `xecret pull` exists for migrations and legacy pipelines. If a task seems to need it, propose `xecret run` instead and explain why. - **Do not put values in commands.** `xecret secrets set NAME` reads from stdin or an interactive prompt, never from argv, precisely so values stay out of shell history and `ps` output. Piping is fine: `openssl rand -hex 32 | xecret secrets set SESSION_SECRET`. - **Never commit `.env`.** Check `.gitignore` covers `.env*` before doing anything else in a repository that has one. - **`.xecret.yaml` is safe to commit** and should be. It contains two slugs. - **Ask before touching production.** Production is deny-by-default for a reason; a command that needs a production grant is a command that needs a human. - **Never paste a secret value into a summary, a commit message, an issue, or a message to the user.** Refer to it by name. ## Next - [Command reference](cli/commands.md) — every command, in one page. - [Tokens](api/tokens.md) — scoping a credential for an agent. - [Teams and access](guides/teams.md) — why production is denied by default. ---