# Security model

seekrit's guarantee is simple: **the server never has the information needed to read your
secrets.** This page states that precisely — including the one feature you can opt into that
changes it, and exactly how far that change reaches.

## What the server stores

- Secret **ciphertext** (AES-256-GCM blobs).
- Environment DEKs, but only **wrapped** to principals' public keys — never in the clear.
- Users' **public keys**, and their **private keys only in passphrase-encrypted form**.
- For service tokens: a **SHA-256 hash** of the token and the token's **public key**.
- Metadata: org/app/environment/secret **names**, versions, timestamps, roles, and the audit log.

A value that [references another secret](/docs/guides/references) is no
different: the server holds the ciphertext of the literal `${OTHER_SECRET}` text
and never resolves it. Expansion happens in the client that already holds the
key, alongside decryption.

## What the server never sees

- Plaintext secret values — with one exception you turn on yourself, described
  in the next section.
- Any DEK in the clear.
- Any user's private key in the clear.
- Any passphrase.
- Any sign-in password — email/password auth is verified by Stytch; seekrit never receives or
  stores it, and it is unrelated to the passphrase that decrypts secrets.
- The full service token string (only its hash).

## The one exception: third-party sync

[Third-party sync](/docs/guides/third-party-sync) pushes an environment's
secrets to a platform that keeps its own copy — a Vercel project's environment
variables or a Cloudflare Worker's secret bindings, for example. Somebody has to
hold the plaintext to send it, and a
sync runs when nobody is logged in, so the sync engine has to be able to decrypt
on its own.

This is the only place seekrit's servers decrypt anything, and it is off unless
you turn it on. The boundaries:

- **Per environment, per destination.** Enabling sync for `production` grants
  nothing for `staging`, and grants nothing to any destination but the one you
  named.
- **You create the grant, not us.** Access is an ordinary
  [key grant](/docs/concepts/access-control) — the environment's data key
  wrapped, in your browser, to a public key belonging to that sync connection.
  The API cannot produce that wrap, so sync cannot be enabled server-side even
  by someone with full control of our infrastructure.
- **The matching private key is not in the database.** It lives in the sync
  engine's own storage. A full database dump still yields only ciphertext.
  Deleting the connection destroys the key, which makes every grant to it
  permanently undecryptable — by us too.
- **Plaintext is transient.** A run decrypts, pushes, and drops the values.
  Nothing is cached between runs, and the grant is re-read every time, so
  revoking it stops the next run.
- **On the record.** Enabling sync requires an explicit acknowledgment, stored
  in the audit log with who did it and when. Every run writes its own audit
  entry — a push is the moment your plaintext leaves seekrit, so it is recorded
  durably rather than sampled.

If you can run code where the secrets are used, you don't need any of this:
[`seekrit run`](/docs/guides/run), the
[egress proxy](/docs/guides/agent-proxy), the
[language SDKs](/docs/guides/sdks), and the
[Kubernetes chart](/docs/guides/kubernetes) all decrypt on your side. Sync is
for platforms whose runtime you cannot get in front of.

## Threat model

**A full database compromise** yields ciphertext and metadata. An attacker learns that
`STRIPE_KEY` exists in `acme/storefront/production` and when it changed, but not its value. To
decrypt, they would additionally need a principal's private key, which the database does not
contain.

**A compromised or malicious server** can deny service, tamper with metadata, or serve altered
ciphertext — but AES-GCM authentication means tampered ciphertext fails to decrypt rather than
producing wrong plaintext silently. It still cannot read secret values, because it never holds
the keys. The exception is bounded by design: an attacker with full control of the control plane
still cannot enable [sync](#the-one-exception-third-party-sync) on an environment, because the
key grant that allows decryption can only be produced client-side by someone who already holds
the key. They could read environments where you had *already* enabled sync — which is why the
grant is per environment, per destination, and revocable.

**A leaked passphrase** exposes that user's private key (and therefore every environment granted
to them). Treat passphrases like the master credential they are.

**A leaked service token** grants whatever environments were wrapped to it. Revoke it; rotate the
affected environment keys.

**A machine with the last-known-good cache enabled** holds a copy of the encrypted resolve
response beside the token that opens it. Anyone who can read that file can already read the token
and fetch the same payload themselves, so it adds no new exposure — but it does outlive the
network. While the API is unreachable, a revoked token keeps working until the entry expires
(`--cache-max-age`, default 24h), and a client served from cache makes no resolve call, so it
raises no `env.resolve_denied` audit entry. A reachable API that refuses the token deletes the
entry immediately. The cache is off unless you turn it on — see the
[CLI reference](/docs/reference/cli#last-known-good-cache).

**A leaked CLI session** (the credential `seekrit login` saves) acts as that user against the
management API, but decrypts nothing on its own: it holds no key material, so reading a secret's
value still requires that user's passphrase. Revoke it from **account → security** or with
`seekrit logout`.

> **Note:** Names are metadata, not secrets. Don't encode sensitive information in org, application, environment, or secret **names** — only in secret **values**.

## Audit trail

Every write, grant, revocation, and token action is recorded in an append-only audit log,
attributed to the acting user or service token, with contextual metadata (never secret material).
The API exposes no update or delete path for audit entries. This gives you an after-the-fact
record of who touched what, even though the server can't read the secrets themselves.

High-volume environment resolves (`GET /v1/resolve`, called on every app or CI
startup) are the deliberate exception: they are metered for usage and billing
rather than written to the audit log per call, so the log stays a signal of
change and access-control events, not a firehose of identical machine reads. A
*denied* resolve — a principal without a key grant — is still audited.

### Exporting to your SIEM

An organization admin can stream the audit trail to an external OTLP/HTTP logs
endpoint (Datadog, Grafana, Splunk, an OpenTelemetry Collector, etc.) for
security monitoring and long-term retention. seekrit ships each new audit event
as an OTLP log record, with **at-least-once** delivery: a per-org watermark
advances only after your collector accepts a batch, so a transient outage
re-ships rather than drops events. The endpoint's auth header is a control-plane
integration credential — encrypted at rest with a server key and never returned
by the API — not a customer secret, so the zero-knowledge invariant is
unaffected: exported records carry only the audit log's redacted metadata, never
secret values. See the [audit log export guide](/docs/guides/audit-export).

## Transport and authentication

- All API traffic is authenticated: a Stytch session JWT (verified locally against Stytch's
  JWKS), a CLI session, or a service token.
- Session JWTs are validated for issuer, audience, and expiry.
- Service tokens and CLI sessions are matched by hash and checked for revocation and expiry on
  every request. Neither is ever stored in a form that could be replayed from a database dump.

## Multi-factor authentication

Members can protect their account with a second factor — a time-based one-time
code (TOTP) from an authenticator app such as 1Password, Google Authenticator,
or Authy. Enroll from **account → security** in the dashboard: scan the QR code,
confirm a code, and save the one-time **recovery codes** shown once (each signs
you in once if you lose your device).

Organization admins can require it for everyone from **organization →
settings → access & security**. With the policy on, any member without a second
factor must set one up before they can access the organization, and every
sign-in is challenged for a code after the primary factor.

Because MFA is enforced by the identity provider before a session is issued, the
second factor gates the session token itself — the API only ever sees an
already-MFA-satisfied session JWT. No secret material or private key is involved,
so the zero-knowledge invariant is unaffected. Enabling, disabling, and changing
the org MFA policy are recorded in the [audit log](#audit-trail).

One action asks for the second factor **again**, mid-session: authorizing a CLI
device (`seekrit login`). That single click mints a 90-day credential, so a member
with MFA enrolled must have entered a code within the last 10 minutes for the
approval to be accepted — checked server-side against the session's authentication
factors, not just prompted for in the UI. A lifted browser session therefore isn't
enough to walk away with a terminal credential.

> **Note:** MFA guards **who can start a session**. It is not a substitute for your passphrase, which is what actually decrypts secrets — see [encryption](/docs/concepts/encryption).

## Responsible disclosure

If you find a vulnerability, please report it privately to
[security@seekrit.dev](mailto:security@seekrit.dev) rather than disclosing it publicly. We'll
acknowledge your report and keep you updated on the fix.
