Documentation

How It Works

Zephr encrypts plaintext on your device, stores only ciphertext, and deletes records after one view.

Browser A Encrypts on your device Ciphertext Zephr Server Zero knowledge storage Ciphertext Browser B Decrypts on their device Auto-deletes

Key Creation

The browser generates a 256-bit Web Crypto API AES-GCM key from device entropy before any network communication.

The raw key bytes are encoded as a versioned string (v1.<base64url>) and embedded in the shareable link's URL fragment, then immediately zeroed from application memory. Due to JavaScript engine behavior, deterministic memory sanitization cannot be ensured.

Encryption

The plaintext is encrypted on your device using the generated key. Each operation uses a unique 96-bit initialization vector (IV). Plaintext never leaves the local environment.

AES-GCM provides both confidentiality and integrity. The ciphertext includes an authentication tag that detects any tampering. Identical inputs produce different outputs due to the random IV.

The encrypted data contains the IV, ciphertext, and authentication tag — everything needed for decryption except the key.

Storage

The encrypted data is transmitted over HTTPS and stored with a cryptographically random 128-bit identifier and expiration metadata. No encryption keys or plaintext data are stored.

The server accepts an encrypted blob, assigns it a random identifier, and returns it on request. It cannot decrypt the blob or verify its contents.

Each stored record contains:

id: "Ht7kR2mNqP3wXvYz8aB4cD" (22-char base64url, 128-bit entropy)
encrypted_blob: "eyJpdiI6IkF..." (base64url ciphertext)
created_at: 1736521800 (Unix timestamp)
expires_at: 1736608200 (Unix timestamp)
consumed_at: null
size_bytes: 1247
split_url_mode: false
version: 1
ttl: 1736694600 (auto-cleanup)

The blob is opaque without the key, which never reaches the server.

The link has two parts: a path identifying the stored blob, and a URL fragment containing the versioned decryption key (v1.<base64url>).

URL fragments (everything after the # symbol) are never sent to servers. This is a fundamental property of URIs defined in RFC 3986. The key travels directly from sender to recipient, bypassing Zephr's servers entirely.

Split mode separates URL and key for transmission through different channels. Both parts are required — URL alone or key alone grants nothing.

Decryption

When the link is opened, the recipient's browser extracts the raw key bytes from the URL fragment and imports them into the Web Crypto API as a non-extractable AES-GCM CryptoKey. Once imported, no code — including the SDK itself — can read back the raw key material; the browser's cryptographic subsystem enforces this boundary. The browser then requests the encrypted blob using the identifier. The server returns the ciphertext and marks the record as consumed in a single atomic operation, preventing successful concurrent retrievals.

AES-GCM verifies the authentication tag as part of decryption. If the data was tampered with, decryption fails completely. The plaintext appears only in that browser, only in memory.

A second attempt returns 404 (never existed) or 410 (consumed or expired). The data is gone.

Destruction

After access, the server marks the record as consumed — it immediately returns 410 on any subsequent request. Physical deletion follows asynchronously via background cleanup. Unaccessed records are removed automatically when they expire.

No decryption keys are stored server-side. Any backups contain only encrypted blobs Zephr cannot decrypt.

Security Layers

  • AES-GCM-256 authenticated encryption — confidentiality and tamper detection in one operation
  • Non-extractable CryptoKey — the decryption key is imported as non-extractable at retrieval time; the browser's cryptographic subsystem prevents key extraction by any code
  • URL fragment isolation — keys travel client-to-client, never touching the server (RFC 3986)
  • Atomic one-time access — concurrent requests cannot retrieve the same record twice
  • HTTPS with HSTS — transport encryption enforced on every request
  • Per-IP rate limiting — brute force and enumeration attacks are blocked at the API layer

No single control is relied upon. These properties emerge from architecture, not policy. See security boundaries for what Zephr cannot control.

Quickstart

Three paths to a one-time link. The CLI needs no installation for one-off use. The JS SDK works isomorphically in Node.js 20+ and browser bundles. The Python SDK is the right choice for Python applications and agent workflows.

CLI

npm install -g zephr
zephr "my-api-key-abc123"
# → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...

JavaScript / TypeScript

npm install zephr

import { createSecret } from 'zephr';

const { fullLink } = await createSecret('my-api-key-abc123');
// → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...

Python

pip install zephr

import zephr

result = zephr.create_secret("my-api-key-abc123")
print(result["full_link"])
# → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...

CLI

Handles encryption and upload in a single command. No dependencies — uses Node.js built-ins only. Reads from stdin, so it composes naturally with password managers, scripts, and automation pipelines. Links open in any browser — the CLI and web app are fully interoperable.

# Global install
npm install -g zephr

# No install required (one-off use, CI, agent environments)
npx zephr "my-secret"
zephr <secret> [options]
echo "secret" | zephr [options]

Options:
  -e, --expiry <hours>   Expiration: 1, 24, 168, or 720 (default: 1; 24h+ requires a free account; 720 requires Dev or Pro)
  -s, --split            Return URL and key separately
  -k, --api-key <key>    API key; takes precedence over ZEPHR_API_KEY env var
  -v, --version          Show version
  -h, --help             Show help

Examples

zephr "my-api-key"                    # 1h expiry
zephr "my-api-key" --expiry 1        # 1h expiry
zephr "my-api-key" --split           # URL and key separately

# Pipe from stdin — scripts, CI pipelines, and agent environments
echo "$API_KEY" | zephr --expiry 1   # from environment variable
pass show production/db | zephr      # from password manager

Authentication

The CLI works without an account. Anonymous requests are capped at 3 per day per IP, 1 h max expiry, and 6 KB per secret. Pass an API key for higher limits. Keys are created at zephr.io/account.

TierCreatesMax expiryMax secret
Anonymous3/day1 h6 KB
Free50/month7 days20 KB
Dev ($15/mo)2,000/month30 days200 KB
Pro ($39/mo)50,000/month30 days1 MB
# Flag — takes precedence over the environment variable
zephr "secret" --api-key zeph_...

# Environment variable — preferred for CI and automation
export ZEPHR_API_KEY=zeph_...
zephr "secret" --expiry 168

# Pro: 30-day expiry
ZEPHR_API_KEY=zeph_... zephr "secret" --expiry 720

Exit codes

CodeMeaning
0Success
1Any error — invalid input, network failure, or upload error

JavaScript SDK

Isomorphic — works in Node.js 20+ and any modern browser via bundler. The zephr npm package ships both the CLI binary and the importable library, so no separate install is needed. TypeScript declarations are bundled. Zero external dependencies — uses globalThis.crypto.subtle and fetch, both available natively in Node.js 20+ and all modern browsers.

npm install zephr
import { createSecret, retrieveSecret } from 'zephr';

createSecret()

createSecret(secret, options?) → Promise<SecretLink>
OptionTypeRequiredDescription
secret string · Plaintext to encrypt. Max 2,048 UTF-8 bytes (2,048 ASCII characters; fewer for multi-byte Unicode). The exact string is encrypted and returned unchanged — no normalisation is applied. The resulting encrypted blob must also fall within the tier's payload limit.
expiry 1 | 24 | 168 | 720 Hours until expiration. Default: 1. 24h+ requires a free account; 720 requires Dev or Pro.
split boolean Return URL and key separately. Default: false
apiKey string | null Bearer token for authenticated requests. Default: null

Return value — standard mode

{
  mode:      'standard',
  fullLink:  'https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...',
  expiresAt: '2026-02-21T12:00:00.000Z',
  secretId:  'Ht7kR2mNqP3wXvYz8aB4cD'
}

Return value — split mode

{
  mode:      'split',
  url:       'https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD',
  key:       'v1.base64url-key...',
  expiresAt: '2026-02-21T12:00:00.000Z',
  secretId:  'Ht7kR2mNqP3wXvYz8aB4cD'
}

Errors

Error classRaised when
ValidationErrorInput is invalid — empty, too long, wrong type, or unsupported expiry
EncryptionErrorAES-GCM key generation or encryption failed
ApiErrorServer returned an error. Has .statusCode (HTTP code) and .code (machine-readable string, e.g. 'MONTHLY_LIMIT_EXCEEDED') properties.
NetworkErrorConnection failed or timed out (10 s)

Error handling

import { createSecret, ValidationError, ApiError, NetworkError } from 'zephr';

try {
  const { fullLink } = await createSecret('my-api-key', { expiry: 1 });
  console.log(fullLink);
} catch (e) {
  if (e instanceof ValidationError) console.error('Invalid input:', e.message);
  else if (e instanceof ApiError)    console.error(`Server error ${e.statusCode}:`, e.message);
  else if (e instanceof NetworkError) console.error('Network error:', e.message);
  else throw e;
}

TypeScript

import { createSecret, retrieveSecret } from 'zephr';
import type { SecretLink, CreateSecretOptions, RetrieveSecretOptions } from 'zephr';

const result: SecretLink = await createSecret('my-api-key', { expiry: 1 });

if (result.mode === 'standard') {
  console.log(result.fullLink); // string — key embedded in fragment
} else {
  console.log(result.url, result.key); // both strings — deliver separately
}

const plaintext: string = await retrieveSecret(result.fullLink);

retrieveSecret()

retrieveSecret(link, options?) → Promise<string>

Fetch and decrypt a one-time secret. The server marks the record consumed on first read — a second call with the same link throws an ApiError with statusCode 410. The decryption key never leaves the local environment; the server only ever holds ciphertext.

OptionTypeRequiredDescription
link string | { url, key } · Standard mode: the full link string including the key in the URL fragment. Split mode: a { url, key } object with the two components delivered separately.
apiKey string | null Bearer token for authenticated requests. Default: null

Example — standard mode

import { createSecret, retrieveSecret } from 'zephr';

// Agent A — encrypt and share
const { fullLink } = await createSecret('sk-live-abc123', { expiry: 1 });

// Agent B — retrieve (atomic; destroys the record on first read)
const plaintext = await retrieveSecret(fullLink);
console.log(plaintext); // 'sk-live-abc123'

Example — split mode

// Agent A — split: URL in task context, key through a separate secure channel
const { url, key } = await createSecret('db-password', { split: true, expiry: 1 });
agentB.dispatch({ credentialUrl: url });
sideChannel.send(key); // key never shares a channel with the URL

// Agent B — reconstruct from separately delivered components
const plaintext = await retrieveSecret({ url, key });

Errors

Error classWhenstatusCode
ValidationErrorLink is malformed or missing key
EncryptionErrorKey import or AES-GCM decryption failed
ApiErrorSecret not found or expired404
ApiErrorSecret already consumed (one-time)410
ApiErrorRate limit exceeded429
NetworkErrorConnection failed or timed out (10 s)

End-to-end agent workflow

Agent A encrypts a credential and hands off the link. Agent B retrieves it exactly once. The plaintext is never in a prompt, a log, or shared memory — only in transit as ciphertext.

import { createSecret, retrieveSecret, ApiError } from 'zephr';

// ── Agent A ──────────────────────────────────────────────────
const { fullLink } = await createSecret(process.env.DB_PASSWORD, { expiry: 1 });
agentB.dispatch({ task: 'connect-db', credential: fullLink });

// ── Agent B ──────────────────────────────────────────────────
const { credential } = await receiveTask();
try {
  const password = await retrieveSecret(credential);
  await db.connect({ password });
} catch (err) {
  if (err instanceof ApiError && err.statusCode === 410)
    throw new Error('Credential already used — request a fresh one.');
  throw err;
}

Python SDK

One call handles key generation, encryption, upload, and link assembly — pass a string, get back a shareable link. The right choice for applications, automation, and AI agent workflows. Requires Python 3.10+. One dependency: cryptography, audited and widely used.

pip install zephr

create_secret()

zephr.create_secret(secret, *, expiry_hours=1, split=False, api_key=None) → dict
ParameterTypeRequiredDescription
secret str · Plaintext to encrypt. Max 2,048 UTF-8 bytes. The resulting encrypted blob must also fall within the tier's payload limit.
expiry_hours 1 | 24 | 168 | 720 Hours until expiration. Default: 1. 24h+ requires a free account; 720 requires Dev or Pro.
split bool Return URL and key separately. Default: False
api_key str | None Bearer token for authenticated requests. Default: None (anonymous)

Return value — standard mode

{
  "mode": "standard",
  "full_link": "https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...",
  "expires_at": "2026-02-21T12:00:00.000Z",
  "secret_id": "Ht7kR2mNqP3wXvYz8aB4cD"
}

Return value — split mode

{
  "mode": "split",
  "url": "https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD",
  "key": "v1.base64url-key...",
  "expires_at": "2026-02-21T12:00:00.000Z",
  "secret_id": "Ht7kR2mNqP3wXvYz8aB4cD"
}

Exceptions

ExceptionRaised when
zephr.ValidationErrorInput is invalid (empty, too long, bad type), or server returned an unexpected response structure
zephr.EncryptionErrorAES-GCM encryption failed
zephr.ApiErrorServer returned an error. Has .status_code attribute.
zephr.NetworkErrorConnection failed or timed out (10s)

Error handling

import zephr

try:
    result = zephr.create_secret("my-api-key", expiry_hours=1)
    print(result["full_link"])
except zephr.ValidationError as e:
    print(f"Invalid input: {e}")
except zephr.ApiError as e:
    print(f"Server error {e.status_code}: {e}")
except zephr.NetworkError as e:
    print(f"Network error: {e}")

retrieve_secret()

zephr.retrieve_secret(link, *, api_key=None) → str

Fetch and decrypt a one-time secret. The server marks the record consumed on first read — a second call with the same link raises ApiError with status_code 410. The decryption key never leaves the local environment; the server only ever holds ciphertext.

ParameterTypeRequiredDescription
link str | dict · Standard mode: the full link string including the key in the URL fragment. Split mode: a {"url": ..., "key": ...} dict with the two components delivered separately.
api_key str | None Bearer token for authenticated requests. Default: None

Returns

str — the decrypted plaintext of the secret, exactly as it was passed to create_secret(). Raises on any error condition; never returns None.

Exceptions

ExceptionWhenstatus_code
zephr.ValidationErrorLink is malformed or missing key
zephr.EncryptionErrorKey import or AES-GCM decryption failed
zephr.ApiErrorSecret not found or expired404
zephr.ApiErrorSecret already consumed (one-time)410
zephr.ApiErrorRate limit exceeded429
zephr.NetworkErrorConnection failed or timed out (10 s)

Agent workflows

The SDK is designed for agent-to-agent credential passing. Agent A encrypts the plaintext and hands off the link; Agent B opens it once to retrieve it. The plaintext is never in a prompt, a log, or shared memory — only in transit as ciphertext.

import zephr

# ── Agent A ──────────────────────────────────────────────────
db_password = os.environ["DB_PASSWORD"]   # plaintext only in this process
result = zephr.create_secret(db_password, expiry_hours=1)
agent_b.dispatch({"task": "connect-db", "credential": result["full_link"]})

# ── Agent B ──────────────────────────────────────────────────
task = agent_b.receive_task()             # {"task": "connect-db", "credential": "https://zephr.io/...#v1.key..."}
try:
    password = zephr.retrieve_secret(task["credential"])
    db.connect(password=password)
except zephr.ApiError as e:
    if e.status_code == 410:
        raise RuntimeError("Credential already used — request a fresh one.")
    raise

Split mode

# Agent A — split: URL in task context, key through a separate channel
api_key = os.environ["THIRD_PARTY_API_KEY"]
result = zephr.create_secret(api_key, split=True, expiry_hours=1)
agent_b.dispatch({"credential_url": result["url"]})
side_channel.send(result["key"])    # key never shares a channel with the URL

# Agent B — reconstruct from separately delivered components
task = agent_b.receive_task()       # {"credential_url": "https://zephr.io/secret/..."}
received_key = side_channel.recv()  # "v1.base64url-key..."
plaintext = zephr.retrieve_secret({"url": task["credential_url"], "key": received_key})

REST API

Advanced integration. Your client must encrypt before calling these endpoints. Structural errors — wrong IV size, missing fields, bad encoding, ciphertext too short for the auth tag — return a 400 immediately. The one silent failure case: a correctly structured blob with wrong crypto parameters will upload successfully but fail at decryption. Use the CLI or Python SDK for all standard workflows.

For integrations in languages without an SDK — Go, Ruby, Java, or any HTTP client. Two endpoints: deposit and retrieve. Base URL: https://zephr.io/api

Authentication is optional. Anonymous requests work within the anonymous tier limits (3 creates/day, 1 h max expiry, 6 KB). Pass an API key via Authorization: Bearer zeph_... to use your account's quota and limits. Create a key at zephr.io/account.

A machine-readable OpenAPI 3.1 spec is available at /openapi.json for LLM function calling, Postman import, and SDK generation.

POST/api/secrets

Deposit an encrypted secret. Returns an ID and expiration timestamp.

Request body

FieldTypeRequiredDescription
encrypted_blob string · base64url-encoded blob — see format below
expiry_hours 1 | 24 | 168 | 720 · Hours until expiration. 720 requires Dev or Pro.
split_url_mode boolean If true, recipient enters the key manually. Default: false

Blob format

Construct a JSON object, then base64url-encode the entire object to produce encrypted_blob. All binary values are base64url-encoded individually first.

{
  "iv":         "<96-bit random IV, base64url>",
  "ciphertext": "<AES-GCM-256 output with 128-bit auth tag appended, base64url>"
}
// → base64url-encode this entire JSON string = encrypted_blob
  • Algorithm: AES-GCM-256. Key: 256 bits, random. IV: 96 bits, random, unique per encryption.
  • Auth tag: 128 bits, appended to the ciphertext bytes before encoding — not a separate field.
  • Minimum decoded ciphertext: 17 bytes (1 byte plaintext + 16 byte auth tag) — enforced server-side with a 400.
  • Encoding: base64url throughout (RFC 4648 §5) — standard base64 is rejected.
  • Link key fragment format: v1.<raw 256-bit key, base64url>

Response — 201 Created

{
  "id": "Ht7kR2mNqP3wXvYz8aB4cD",
  "expires_at": "2026-02-21T12:00:00.000Z"
}

GET/api/secrets/:id

Retrieve and consume a secret. One-time only — the record is marked consumed atomically and returns 410 on any subsequent request.

Response — 200 OK

{
  "encrypted_blob": "eyJpdiI6IkF...",
  "purge_at": "2026-02-21T13:00:00.000Z"
}

purge_at: ISO 8601 UTC timestamp after which the physical record is deleted from storage (includes a server-side grace period beyond expires_at). Clients may ignore this field.

Response — 404 Not Found

{
  "error": {
    "code": "SECRET_NOT_FOUND",
    "message": "Secret not found"
  }
}

Response — 410 Gone

{
  "error": {
    "code": "SECRET_ALREADY_CONSUMED",
    "message": "Secret has already been consumed"
  }
}

// or, if the secret expired before retrieval:
{
  "error": {
    "code": "SECRET_EXPIRED",
    "message": "Secret has expired"
  }
}

Errors

All non-2xx responses follow the same JSON envelope. Use code for programmatic handling — it's stable across releases. The message field is human-readable and intended for debugging, not parsing.

Shape: {"error": {"code": "…", "message": "…"}}

CodeHTTPMeaning
MISSING_* / INVALID_* 400 Request is missing required fields or contains invalid values — check the specific code value
INVALID_API_KEY 401 The provided API key does not exist or has been revoked
SECRET_NOT_FOUND 404 Secret ID does not exist
SECRET_ALREADY_CONSUMED 410 Secret has already been viewed
SECRET_EXPIRED 410 Secret passed its expiration time before being retrieved
PAYLOAD_TOO_LARGE 413 Encrypted blob exceeds the size limit for the tier (6 KB anonymous, 20 KB Free, 200 KB Dev, 1 MB Pro)
UPGRADE_REQUIRED 403 The request requires a higher tier — e.g. expiry > 1 h without an account, or 720 h without a Dev or Pro account
ANON_RATE_LIMIT_EXCEEDED 429 Anonymous daily limit reached (3/day) — create a free account for more
MONTHLY_LIMIT_EXCEEDED 429 Monthly create limit reached for this account (50 Free, 2,000 Dev, 50,000 Pro) — resets at the start of the next calendar month
INTERNAL_ERROR 500 Unexpected server error