documentation

Build on deniable encryption.

REST API, CLI, SDKs, Chrome Extension, Telegram Bot, and MCP server. Everything you need to build on deniable encryption.

Quickstart

From zero to deniable encryption in under 5 minutes.

Hosted API vs local SDK: pick the surface that matches your threat model.

The hosted API endpoints below (/api/encrypt, /api/decrypt, /api/deny) accept plaintext and passwords so we can do the work for you. Convenient, but the server sees the inputs in memory during the call. If your threat model rules that out, the SDK (Apache 2.0) and CLI do the same construction locally with no network calls during local crypto. Both paths produce byte-identical ciphertext. Pick once, swap later by changing one import. Full surface-by-surface table at /what-we-see.

1. Get your free API key

Register here. It takes 10 seconds. You'll get a key like dk_abc123...

POST /api/register
curl -X POST https://deny.sh/api/register \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'

2. Encrypt a message

curl -X POST https://deny.sh/api/encrypt \
  -H "Authorization: Bearer dk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "abandon abandon abandon ability abort about above absent absorb abstract",
    "password1": "real-pass",
    "password2": "control-pass"
  }'

# Returns: {"ciphertext": "a3f2...hex", "controlData": "b7c1...hex"}

3. Add a deniable decoy

Generate new control data that makes the same ciphertext decrypt to a completely different message.

curl -X POST https://deny.sh/api/deny \
  -H "Authorization: Bearer dk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ciphertext": "a3f2...hex",
    "password1": "real-pass",
    "password2": "control-pass",
    "fakeMessage": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
  }'

# Returns: {"controlData": "d4e5...hex"} (new control data for the decoy)

4. Decrypt with either control file

Real message: use original control data23 lines
# Real message: use original control data
curl -X POST https://deny.sh/api/decrypt \
  -H "Authorization: Bearer dk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ciphertext": "a3f2...hex",
    "controlData": "b7c1...hex",
    "password1": "real-pass",
    "password2": "control-pass"
  }'
# "abandon abandon abandon ability abort about above absent absorb abstract"

# Decoy message: same ciphertext, different control data
curl -X POST https://deny.sh/api/decrypt \
  -H "Authorization: Bearer dk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ciphertext": "a3f2...hex",
    "controlData": "d4e5...hex",
    "password1": "real-pass",
    "password2": "control-pass"
  }'
# "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"

Same ciphertext. Different control data. Two completely different truths. Mathematically indistinguishable.

5. Or use the CLI instead

# Install
npm install -g deny-sh

# Encrypt
deny-sh encrypt -m "my secret" -p1 "real-pass" -p2 "control-pass" -o encrypted.dat

# Decrypt
deny-sh decrypt -i encrypted.dat -c control.dat -p1 "real-pass" -p2 "control-pass"

# Pipe from stdin
echo "my secret" | deny-sh encrypt -p1 "real-pass" -p2 "control-pass"

Authentication

All API endpoints (except registration and health) require a Bearer token:

Authorization: Bearer dk_your_api_key_here

Get your key from /register or via POST /api/register.


Get an API key

POST /api/register

{
  "email": "you@example.com",
  "name": "My App"          // optional
}

201
{
  "key": "dk_abc123...",
  "tier": "free",
  "monthly_limit": 500,
  "message": "Store your API key securely."
}

One key per email. If you've already registered, you'll get a 409. Store your key when you first receive it.


SDKs

All SDKs produce identical ciphertext. Encrypt in TypeScript, decrypt in Go. Full cross-language compatibility, verified with shared KAT (Known Answer Test) vectors.

TypeScript / Node.js

npm install deny-sh
"my secret"16 lines
import { encrypt, decrypt, generateControlData, generateDeniableControl } from 'deny-sh';

const plaintext = new TextEncoder().encode('my secret');
const controlData = generateControlData(plaintext.length + 4);
const { ciphertext } = encrypt(plaintext, {
  password1: 'real-pass',
  password2: 'control-pass',
  controlData
});

const { plaintext: decrypted } = decrypt(ciphertext, {
  password1: 'real-pass',
  password2: 'control-pass',
  controlData
});
console.log(new TextDecoder().decode(decrypted)); // "my secret"

Python

pip install deny-sh

Go

go get github.com/deny-sh-crypto/deny-go/v2

Rust

cargo add deny-sh

Full SDK documentation and examples: deny.sh/sdks


Honey Mode NEW

Honey Mode collapses decoy generation and arming into a single call. You encrypt a structured secret declaring its type; the wrong password no longer returns the noise of a failed decrypt. It returns a fresh, validator-gated, shape-correct fake of that exact type. The fake is generated deterministically at decrypt time and is seeded from the wrong-password bytes (salt-bound, type-tagged), so it is stable per wrong password and reveals nothing about the real secret. Different fake bytes per record, no decoy pool to maintain.

Structured types only. Honey Mode is eligible across 63 of the 69 credential types. It refuses the two unstructured catch-alls (generic, freeform-secret) and four JSON/URI types (jwt-token, postgres-uri, mongodb-uri, gcp-service-account-key) — data with no fixed shape can't be faked convincingly, so the SDKs throw rather than pretend. For those, use the classic encrypt() / decrypt() path with a hand-written decoy. Live in every reference SDK (npm, PyPI, crates, Go).

Note on branch. The decrypt result carries a branch field ('real' or 'honey') for internal telemetry and tests only. The two branches are designed to be externally indistinguishable; never surface branch to an end user or attacker.

TypeScript / Node.js

import { encryptHoney, decryptHoney } from 'deny-sh';

const res = await encryptHoney({
  secret: 'sk_live_51H8x...real',
  passwords: { p1: 'real-pass', p2: 'control-pass' },
  honeyType: 'stripe-live-key',
});

// Right passwords -> the real secret.
const real = await decryptHoney(
  res.ciphertext, res.realCtrl,
  { p1: 'real-pass', p2: 'control-pass' },
  res.honeyType, res.band,
);
// real.value is the real secret; branch: 'real'

// Wrong password -> a fresh, type-correct fake.
const fake = await decryptHoney(
  res.ciphertext, attackerCtrl,
  { p1: 'wrong', p2: 'wrong' },
  res.honeyType, res.band,
);
// fake.value is a plausible decoy; branch: 'honey'

Python

from deny_sh import encrypt_honey, decrypt_honey

res = encrypt_honey(
    secret, p1, p2, 'stripe-live-key',
)

out = decrypt_honey(
    res.ciphertext, ctrl, p1, p2,
    res.honey_type, res.band,
)
# out.value: real secret on right pw, typed fake on wrong

Go

import deny "github.com/deny-sh-crypto/deny-go/v2"

res, _ := deny.EncryptHoney(
    secret, p1, p2, "stripe-live-key",
)

out, _ := deny.DecryptHoney(
    res.Ciphertext, ctrl, p1, p2,
    res.HoneyType, res.Band,
)
// out.Value: real on right pw, typed fake on wrong

Rust

use deny_sh::{encrypt_honey, decrypt_honey};

let res = encrypt_honey(
    secret, p1, p2, "stripe-live-key",
)?;

let out = decrypt_honey(
    &res.ciphertext, &ctrl, p1, p2,
    &res.honey_type, res.band,
)?;
// out.value: real on right pw, typed fake on wrong

CLI

deny-sh encrypt -m "sk_live_51H8x...real" -p1 "real-pass" -p2 "control-pass" \
  --honey --honey-type stripe-live-key

--honey requires --honey-type <type>; the CLI rejects unknown or honey-ineligible types up front. The encrypt step prints the type and band metadata you persist alongside the record (both are required inputs to decryptHoney).


POST Encrypt

Encrypt a message with two passwords. Returns ciphertext and control data (both hex-encoded).

POST /api/encrypt

{
  "message": "launch codes: 38.8977 N, 77.0365 W",
  "password1": "correct-horse",
  "password2": "battery-staple",
  "controlDataHex": "optional...hex"  // optional: supply your own control data
}

200
{
  "ciphertext": "a3f2...hex",
  "controlData": "b7c1...hex"
}
ParameterTypeDescription
message requiredstringPlaintext to encrypt. Max 10MB.
password1 requiredstringPrimary password. Max 1024 chars.
password2 requiredstringSecondary password. Max 1024 chars.
controlDataHexstringOptional hex-encoded control data. Auto-generated if omitted.

Store both the ciphertext and control data. You need the control data to decrypt or create decoys.


POST Decrypt

POST /api/decrypt

{
  "ciphertext": "a3f2...hex",
  "controlData": "b7c1...hex",
  "password1": "correct-horse",
  "password2": "battery-staple"
}

200
{
  "message": "launch codes: 38.8977 N, 77.0365 W"
}
ParameterTypeDescription
ciphertext requiredstringHex-encoded ciphertext.
controlData requiredstringHex-encoded control data.
password1 requiredstringPrimary password.
password2 requiredstringSecondary password.

POST Create decoy PAID

Generate new control data that makes the same ciphertext decrypt to a completely different message. Requires an API key; metered against your plan's daily decoy quota (Free included).

POST /api/deny27 lines
POST /api/deny

{
  "ciphertext": "a3f2...hex",
  "password1": "correct-horse",
  "password2": "battery-staple",
  "fakeMessage": "grocery list: milk, eggs, bread"
}

200
{
  "controlData": "d4e5...hex"
}

// Decrypt the SAME ciphertext with the new control data:
POST /api/decrypt
{
  "ciphertext": "a3f2...hex",          // same ciphertext
  "controlData": "d4e5...hex",          // new control data
  "password1": "correct-horse",
  "password2": "battery-staple"
}

200
{
  "message": "grocery list: milk, eggs, bread"  // different truth
}
ParameterTypeDescription
ciphertext requiredstringHex-encoded ciphertext from a previous encrypt call.
password1 requiredstringPrimary password (same as used for encryption).
password2 requiredstringSecondary password (same as used for encryption).
fakeMessage requiredstringThe decoy plaintext. Must be shorter than or equal to the original message length.

POST Suggest realistic decoys

The realism engine can suggest shape-correct decoy plaintexts before you create deniable control data. Authenticated calls use your API key's daily decoy allowance; unauthenticated calls from the public encrypt page use a small per-IP burst limit.

POST /v1/decoy/suggest
Authorization: Bearer dk_live_...
Content-Type: application/json

{ "explicit_type": "stripe-test-key", "n_decoys": 3 }

200
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1770019200

{
  "type_detected": "stripe-test-key",
  "decoys": [
    { "decoy_text": "sk_test_51Nz...", "plausibility_score": 0.94, "source": "llm" }
  ],
  "cache_hit": false,
  "generation_latency_ms": 842
}

Quota windows are sliding 24-hour windows. X-RateLimit-Reset is a Unix epoch second: for an allowed call it is roughly now plus 24 hours; after a 429 it is the time the oldest successful call leaves the window.

TierDaily decoy calls
Free10
Dev100
Pro1,000
Scale25,000
Agents Infrastructure100,000
Enterprise1,000,000
POST /v1/decoy/suggest

429
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1770019200
Retry-After: 38142

{ "error": "tier_daily_limit", "limit": 10, "reset_at": 1770019200 }

The public no-auth path uses the same response headers, but the 429 body is { "error": "ip_burst_limit", "limit": 30, "reset_at": 1770019200 } and the window is 30 calls per IP per hour.


POST Text encrypt NEW

Simplified text-only encryption. Returns hex strings directly (no binary). Control data is auto-generated.

POST /api/text/encrypt

{
  "message": "my secret",
  "password1": "real-pass",
  "password2": "control-pass"
}

200
{
  "ciphertextHex": "a3f2...hex",
  "controlData": "b7c1...hex"
}

POST Text decrypt NEW

Decrypt a text-encrypted message. Accepts both ciphertextHex and ciphertext field names.

POST /api/text/decrypt

{
  "ciphertextHex": "a3f2...hex",
  "controlData": "b7c1...hex",
  "password1": "real-pass",
  "password2": "control-pass"
}

200
{
  "message": "my secret"
}

POST Generate control data NEW

Generate random control data of a specific size. Useful when you want to manage control data separately from encryption.

POST /api/generate-control

{
  "size": 1024
}

200
{
  "controlData": "a1b2c3...hex"
}
ParameterTypeDescription
size requirednumberByte length of control data. 1 to 10,485,760 (10MB).

GET Check usage

GET /api/usage

200
{
  "tier": "free",
  "calls_this_month": 42,
  "limit": 500,
  "remaining": 458,
  "resets_at": "2026-06-01T00:00:00.000Z"
}

GET Health NO AUTH

Check if the API is running. No authentication required.

GET /api/health

200
{
  "status": "ok",
  "db": "ok"
}

GET /api/health
x-admin-token: <admin-token>

200
{
  "status": "ok",
  "version": "2.3.1",
  "uptime": 12345,
  "db": "ok",
  "process": "deniable-crypto"
}

Vault PAID

Encrypted control file storage. Files are encrypted client-side before upload. Vault item limits vary by tier (Free 25 items through Agents Infrastructure 10,000).

Two interfaces, one vault. The endpoints below are the underlying API. Humans typically touch the vault through one of two UI surfaces: the browser client for adding and fetching secrets with two passwords, and the vault inventory for listing, relabelling, and deleting what's already stored (metadata only, never decrypts). Both surfaces, the SDK, the CLI, and the MCP server all read and write the same items via these endpoints.

POST /api/vault/store17 lines
POST /api/vault/store
{
  "label": "Main wallet backup",
  "encryptedData": "aabbccdd...hex",
  "iv": "1122...hex",
  "salt": "3344...hex"
}
201  { "id": "vi_abc123..." }

GET /api/vault/list
200  { "items": [{ "id", "label", "size", "created_at" }], "count": 1 }

GET /api/vault/:id
200  { "id", "label", "encryptedData", "iv", "salt", "size", "created_at" }

DELETE /api/vault/:id
200  { "deleted": true }

Vault limits: Free = 25 items, Dev = 1,000 items, Pro = 10,000 items, Agents Infrastructure = 10,000 items. Max 1MB per item.


Audit log PAID

Full audit trail of every API operation, hash-chained per tenant (each entry binds the previous, so any tampered record breaks the chain). Background worker timestamps every entry against RFC 3161 TSAs (FreeTSA primary, DigiCert fallback) with nonce-verified responses. Available on paid tiers. Dev and Pro retain 90 days; Scale, Agents Infrastructure and Enterprise retain 365 days.

GET /api/audit
200
{
  "entries": [
    { "action": "vault.store", "resource": "vi_abc123", "ip": "...", "timestamp": ... },
    { "action": "encrypt", "resource": "", "ip": "...", "timestamp": ... }
  ]
}

Pull a signed PDF receipt for any single entry from the dashboard at /dashboard/audit, or verify a receipt offline with the open-source verifier CLI: npx @deny-sh/verify-receipt path/to/receipt.json. The verifier re-walks the chain head from the receipt and validates the TSA-issued CMS SignedData against the bundled root certificates with no network access required.


Decoy tripwires PAID

Arm a decoy so you get paged the moment it's touched. Hash the decoy locally, register the 32-byte SHA-256 fingerprint, and the first time that byte string is matched at a decrypt endpoint anywhere in your tenant we fire a critical decoy.tripwire.triggered audit-chain event and fan it out to your webhooks (Datadog, PagerDuty, Slack) within roughly five seconds. Only the hash crosses the network; the decoy bytes stay yours. Available on Pro, Agents Starter, Agents Infrastructure, and Enterprise tiers.

Two kinds, matched at different points in the decrypt:

  • controldata (default): the SHA-256 of a decoy control-data file (the deniable-encryption fingerprint). Matched before the decrypt runs, so it fires whether or not the attacker has the right password.
  • plaintext: the SHA-256 of a decoy plaintext (a fake API key, a fake seed phrase). Matched after a successful decrypt against the recovered bytes. This is what /agents “Arm This Bundle” registers for each fake credential it generates.
# hash the decoy locally; only the hash crosses the network
HASH=$(printf '%s' "$DECOY_VALUE" | sha256sum | cut -d' ' -f1)

POST /v1/decoy-tripwires
Authorization: Bearer dk_live_...   # API key, or cs_* dashboard session
{
  "tripwire_hash": "<64-char lowercase sha256 hex>",
  "tripwire_kind": "plaintext",       // or "controldata" (default)
  "label": "stripe-prod decoy",
  "note": "seeded in the support inbox"
}

200
{ "tripwire": { "id": 41, "tripwire_hash_suffix": "...3f9a1c", "tripwire_kind": "plaintext", "label": "stripe-prod decoy", "enabled": true } }

List, disable, re-enable, and revoke:

GET    /v1/decoy-tripwires             # list (hash suffix only, never the full hash)
POST   /v1/decoy-tripwires/:id/disable  # keep the row, stop alerting
POST   /v1/decoy-tripwires/:id/enable   # resume alerting
DELETE /v1/decoy-tripwires/:id          # revoke (deletes the row)

Arm decoys at customer-DB scale with one round-trip. Bulk accepts up to 1,000 tripwires per call and writes back per-index results so you can join the ids into your own table:

POST /v1/decoy-tripwires/bulk
{
  "tripwires": [
    { "tripwire_hash": "<hex>", "tripwire_kind": "plaintext", "label": "tenant-1029 openai" },
    { "tripwire_hash": "<hex>", "tripwire_kind": "plaintext", "label": "tenant-1029 stripe" }
  ]
}

200
{
  "registered": [ { "index": 0, "tripwire": { "id": 88, ... } }, { "index": 1, "tripwire": { "id": 89, ... } } ],
  "failed": []
}

Per-tenant tripwire caps scale with tier (1M on Agents Infrastructure). Every register, revoke, and match lands in the audit chain. The one-command bulk-arm recipe lives at /agents/arm-at-scale; manage them in the dashboard at /dashboard/decoy-alerts.

Live probe feed

Poll the event feed to watch decoy probes as they land. Each event is enriched in-memory with network context: the autonomous-system number and operator, the country, and the user-agent family. A probe from a cloud provider reads as a datacenter ASN, which is how you separate a curious user from a scanner. The feed honours per-tier retention (Free 7 days, Pro 90 days, Agents Infrastructure and Enterprise unlimited).

GET /v1/decoy-tripwires/events?limit=50&since=<unix>
Authorization: Bearer dk_live_...   # API key, or cs_* dashboard session

200
{
  "events": [
    {
      "id": 9213,
      "tripwire_id": 41,
      "fired_at": 1782... ,
      "tripwire_kind": "plaintext",
      "ip_trunc": "5.9.122.0/24",        // never a full IP (see privacy note)
      "asn": 24940,
      "asn_org": "Hetzner Online GmbH",
      "geo_country": "DE",
      "ua_family": "curl",
      "cross_tenant_count_24h": 1412     // network-wide matches of this signature
    }
  ],
  "retention_days": 90,
  "now": 1782...
}

Privacy: deny.sh never stores a raw attacker IP. At capture the address is truncated to a /24 (IPv4) or /48 (IPv6), the ASN and country are derived from a local MaxMind GeoLite2 database in memory, and the full address is discarded before anything is written. Only the truncated network and the derived fields persist.

Cross-tenant threat score PRO

Every probe is reduced to a privacy-preserving signature (an HMAC over ASN, user-agent family, tripwire kind, and the hour) and rolled up across all tenants. The cross_tenant_count_24h on each feed event, and the standalone score endpoint below, tell you whether the access pattern you just saw is isolated to you or part of a coordinated sweep hitting the whole network. This is the one signal a self-hoster structurally cannot reproduce, because they only ever see their own traffic.

GET /v1/threat/score?pattern=<64-char hex pattern_hash>
Authorization: Bearer dk_live_...

200
{
  "pattern_hash": "a1b2...",
  "counts": { "last_1h": 88, "last_24h": 1412, "last_30d": 9006 },
  "updated_at": 1782...
}

The signature is an HMAC, so the rollup table never reveals a tenant, a decoy, or a raw IP. Available on Pro, Scale, Agents Starter, Agents Infrastructure, and Enterprise tiers. The decoy.tripwire.triggered webhook payload also carries the network context and cross-network count, so your on-call page already says “and 1,400 others saw this pattern” without a second call.


Managed Vault API

Managed vault with higher limits, audit trails, and region selection. Pro and Agents Infrastructure tiers, or custom Enterprise contracts.

POST Create vault

POST /api/managed-vault
{ "label": "Production keys" }

201
{ "id": "mv_abc123...", "tier": "business", "max_items": 10000 }

The tier field here is an internal managed-vault capacity label derived from your API key tier; it is not one of the public pricing tiers. It only governs managed-vault item limits.

GET List vaults

GET /api/managed-vault
200
{ "vaults": [...], "count": 2 }

POST Store item

POST /api/managed-vault/:vid/items
{
  "label": "Stripe live key",
  "encryptedData": "aabbcc...",
  "iv": "001122...",
  "salt": "334455...",
  "region": "eu-west"          // optional, default eu-west
}

201
{ "id": "mvi_def456..." }

GET List items

GET /api/managed-vault/:vid/items
200
{ "items": [{ "id": "mvi_...", "label": "...", "size": 256, "region": "eu-west", "created_at": ... }], "count": 5 }

GET Get item

GET /api/managed-vault/:vid/items/:iid
200
{ "id": "mvi_...", "label": "...", "encryptedData": "...", "iv": "...", "salt": "...", "size": 256, "region": "eu-west", "created_at": ... }

DEL Delete item

DELETE /api/managed-vault/:vid/items/:iid
200
{ "deleted": true }

GET Audit log

GET /api/managed-vault/:vid/audit
200
{
  "entries": [
    { "action": "item.store", "resource": "mvi_abc123", "ip": "...", "timestamp": ... }
  ]
}

BYOK (AWS KMS) PAID

Bring-your-own-key envelope encryption for server-stored ciphertext. Available on agents-infra and enterprise tiers. No extra SKU.

The deny.sh primitive is unchanged: passwords stay on your machine, the SDK encrypts in the browser/CLI/SDK, the server only ever sees opaque ciphertext bytes. BYOK wraps those bytes again with a per-record AES-256-GCM data encryption key (DEK) whose DEK is itself encrypted under your AWS KMS CMK. A DB-only breach yields neither plaintext nor (already client-encrypted) ciphertext: the attacker also needs KMS access in your AWS account.

BYOK applies only to the two server-stored at-rest surfaces:

  • vault_items.encrypted_data: managed Vault entries

The encrypt/decrypt API surface (/v1/encrypt, /v1/decrypt) is unaffected because nothing on those paths is server-stored.

Setup walkthrough

Full step-by-step at /byok-walkthrough. Four steps in summary:

  1. Create a symmetric AES-256 CMK in AWS KMS in the region you want.
  2. Create an IAM role in your account with a trust policy naming deny.sh's account as principal, an sts:ExternalId condition with your tenant ID, and permissions kms:GenerateDataKey + kms:Decrypt scoped to your CMK ARN. Mirror the same allow in the CMK's key policy.
  3. Paste both ARNs plus the region into /dashboard/byok.
  4. Click Register. We immediately STS AssumeRole and roundtrip a verify call. On success, your state flips to active.

IAM trust policy template

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowDenyShAssumeRole",
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::<DENY_SH_AWS_ACCOUNT_ID>:root" },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": { "sts:ExternalId": "<YOUR_TENANT_ID>" }
    }
  }]
}

The ExternalId condition closes the confused-deputy class. Both values are shown in your BYOK dashboard.

IAM role inline permission policy

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyShEnvelopeOps",
    "Effect": "Allow",
    "Action": [ "kms:GenerateDataKey", "kms:Decrypt" ],
    "Resource": "<YOUR_CMK_ARN>"
  }]
}

KMS key policy addition

KMS resource policies and IAM policies are AND-ed, so the CMK's key policy must also allow the role:

{
  "Sid": "AllowDenyShRoleToUseKey",
  "Effect": "Allow",
  "Principal": { "AWS": "<YOUR_ROLE_ARN>" },
  "Action": [ "kms:GenerateDataKey", "kms:Decrypt", "kms:DescribeKey" ],
  "Resource": "*"
}

Config states

statemeaning
pending_verificationConfig registered, verify roundtrip in flight.
activeVerify succeeded. New writes envelope-wrap. Reads unwrap on demand.
failedVerify failed. See failure_reason for the error code. Existing rows unaffected.
revokedConfig disabled. New writes stored unwrapped. Reads of wrapped historical rows return BYOK_UNAVAILABLE until re-registered.

Failure-mode reference

codemeaningretryable
BYOK_ROLE_NOT_ASSUMEDSTS AssumeRole denied. Trust policy missing or condition mismatch.no
BYOK_KEY_NOT_FOUNDCMK ARN doesn't resolve. Wrong account, wrong region, or deleted.no
BYOK_KEY_DISABLEDCMK exists but is disabled or pending deletion.no
BYOK_REGION_MISMATCHCMK ARN's region differs from the registered region.no
BYOK_PERMISSION_DENIEDRole assumed but kms:GenerateDataKey or kms:Decrypt missing.no
BYOK_UNAVAILABLETransient AWS error or 5xx. Safe to retry.yes
BYOK_ENVELOPE_CORRUPTStored envelope failed integrity check. Should never happen.no

Routes

All BYOK routes are gated by a consumer session cookie (cs_*) from /login and a BYOK-eligible tier.

POST Register BYOK config

POST /v1/byok/config
Cookie: cs_***

{
  "cmk_arn": "arn:aws:kms:us-east-1:<you>:key/<uuid>",
  "iam_role_arn": "arn:aws:iam::<you>:role/deny-sh-byok-role",
  "region": "us-east-1"
}

200
{
  "state": "active",
  "cmk_arn": "<...>",
  "iam_role_arn": "<...>",
  "region": "us-east-1",
  "verified_at": 1764000000
}

GET Read BYOK config

GET /v1/byok/config

200
{ "cmk_arn": "...", "state": "active", "verified_at": ..., ... }

POST Re-verify BYOK config

Forces a fresh STS AssumeRole + KMS roundtrip. Useful after rotating the IAM role or updating the key policy. State updates to active or failed on completion.

POST /v1/byok/verify

200
{ "state": "active", "verified_at": ... }

DELETE Revoke BYOK config

Sets state to revoked. From that moment, new writes are stored unwrapped; reads of historical wrapped rows return BYOK_UNAVAILABLE. Re-register the same CMK to un-dark them.

DELETE /v1/byok/config

200
{ "state": "revoked" }

Audit-chain op vocabulary

Every BYOK lifecycle event lands in the hash-chained audit log. Filter /dashboard/audit by op_type prefix byok. to see just BYOK ops. ARN suffixes only (last 12 chars) appear in payloads; never the full ARN.

op_typefires when
byok.config.createdCustomer registers a CMK + role.
byok.config.verifiedRoundtrip succeeds; state moves to active.
byok.config.rotatedCustomer swaps the CMK ARN in place.
byok.config.revokedCustomer revokes; existing envelopes go dark.
byok.envelope.createdA vault write was wrapped.
byok.envelope.unwrappedA vault read was unwrapped.
byok.kms.failureAny STS or KMS error during the above ops. Payload carries the error code.

You also see our calls on your side: every kms:GenerateDataKey and kms:Decrypt shows in your AWS CloudTrail under the IAM role you provisioned, with our STS session name as deny-sh-byok-<tenant-prefix>. Cross-reference our hash-chained audit and your CloudTrail any time.

POST Create checkout session NEW

Generate a Stripe checkout URL for upgrading to a paid plan.

POST /api/billing/checkout

{
  "plan": "dev",                              // "dev", "pro", "dev-annual", "pro-annual"
  "successUrl": "https://deny.sh/docs",       // optional
  "cancelUrl": "https://deny.sh/pricing"      // optional
}

200
{
  "url": "https://checkout.stripe.com/c/pay/..."
}

POST Customer portal NEW

Generate a link to the Stripe customer portal for managing subscriptions, invoices, and payment methods.

POST /api/billing/portal

200
{
  "url": "https://billing.stripe.com/p/session/..."
}

GET List plans NEW

Get all available billing plans with pricing.

GET /api/billing/plans

200
{
  "plans": [
    { "id": "dev", "name": "deny.sh Dev", "price": "$99/mo", "interval": "month", "calls": 10000, "oneTime": false },
    { "id": "pro", "name": "deny.sh Pro", "price": "$199/mo", "interval": "month", "calls": 100000, "oneTime": false },
    { "id": "dev-annual", "name": "deny.sh Dev (Annual)", "price": "$950/yr", "interval": "year", "calls": 10000, "oneTime": false },
    { "id": "pro-annual", "name": "deny.sh Pro (Annual)", "price": "$1,910/yr", "interval": "year", "calls": 100000, "oneTime": false },  ]
}

CLI NEW

Command-line tool for local crypto with no network calls. No API key needed for local operations.

Install

npm install -g deny-sh

Commands

Encrypt a message37 lines
# Encrypt a message
deny-sh encrypt -m "my secret" -p1 "pass1" -p2 "pass2" -o encrypted.dat

# Decrypt
deny-sh decrypt -i encrypted.dat -c control.dat -p1 "pass1" -p2 "pass2"

# Create a deniable decoy
deny-sh deny -i encrypted.dat -p1 "pass1" -p2 "pass2" -m "fake message" -o decoy-control.dat

# Protect a seed phrase (interactive)
deny-sh protect

# Encrypt .env files
deny-sh env protect .env
deny-sh env restore .env.deny

# Local vault (SQLite, never leaves your machine)
deny-sh vault set my-key "my-value"
deny-sh vault get my-key
deny-sh vault list
deny-sh vault delete my-key

# Generate random control data
deny-sh generate -s 1024 -o control.dat

# Run verification suite
deny-sh verify

# Check installation status
deny-sh status

# Initialize .deny/ directory in your project
deny-sh init

# Pipe from stdin
echo "my secret" | deny-sh encrypt -p1 "pass1" -p2 "pass2"
cat secrets.json | deny-sh encrypt -p1 "pass1" -p2 "pass2" -o secrets.enc

.deny/ directory convention

Run deny-sh init in any project to create a .deny/ directory. It auto-generates a .gitignore entry so your encrypted files never get committed. Control files, encrypted backups, and vault data all live here.

Full CLI docs: deny.sh/cli


Chrome Extension NEW

Encrypt and decrypt directly in your browser. Right-click any text to encrypt it in-place.

Features

  • Popup with Encrypt, Decrypt, and Vault tabs
  • Right-click context menu for in-page encryption
  • Shadow DOM panel (no CSS conflicts with host page)
  • File drag-and-drop for binary encryption
  • Auto-save and find control files
  • Clipboard auto-clear after 30 seconds
  • Manifest V3, Web Crypto API (all crypto runs locally)

Install

Install the live Chrome extension from the Chrome Web Store, or load the extension/ directory as an unpacked extension for local development.

Full extension docs: deny.sh/extension


Telegram Bot NEW

Deniable encryption inside Telegram. @denyshbot

Commands

/start     - Get started
/encrypt   - Encrypt a message (interactive, multi-step)
/decrypt   - Decrypt a ciphertext
/protect   - Protect a seed phrase (BIP-39 validated)
/vault     - List saved control files
/help      - Show all commands
/cancel    - Cancel current operation

How it works

  • Multi-step conversation flows guide you through encryption
  • Password messages are auto-deleted from chat
  • Control files sent as .dat documents
  • Per-user SQLite vault for storing control files
  • DM-only for crypto operations (groups redirect to DM)
  • Reply to any message with /encrypt to encrypt it

Full bot docs: deny.sh/telegram


MCP Server NEW

Model Context Protocol server for AI agents. 10 tools: 3 run locally (zero knowledge, no API key), 7 use the API.

Local tools (no API key needed)

deny_local_encrypt      - Encrypt text locally
deny_local_decrypt      - Decrypt text locally
deny_local_create_decoy - Generate deniable control data locally

API tools (require API key)

deny_encrypt      - Encrypt via API
deny_decrypt      - Decrypt via API
deny_create_decoy - Create decoy via API
deny_vault_store  - Store to vault
deny_vault_list   - List vault items
deny_vault_get    - Get vault item
deny_usage        - Check API usage

Configuration

// Add to your MCP client config (e.g. claude_desktop_config.json)
{
  "mcpServers": {
    "deny-sh": {
      "command": "node",
      "args": ["path/to/deny-mcp-server.cjs"],
      "env": {
        "DENY_API_KEY": "dk_your_key_here"  // optional, only for API tools
      }
    }
  }
}

Full MCP docs: deny.sh/agents


Integrations

Optional sync targets for encrypted control files and operational workflows. They sit outside the cryptographic primitive; local encrypt, decrypt, and decoy creation keep working without them.

Agent framework adapters NEW

Official adapters wrap the core SDK as a vault-entry-as-tool for the major agent frameworks. The credential resolves and is consumed inside the tool boundary; only a narrowed DTO returns to the model, and a fail-closed leak sweep throws if the raw secret appears anywhere in it. The deniability boundary is cryptographic, not policy. Each adapter adds nothing to the security model beyond the shared core.

# LangChain (Python / TypeScript)
pip install deny-sh-langchain
npm i deny-sh-langchain @langchain/core

# Vercel AI SDK (TypeScript)
npm i deny-sh-vercel-ai ai

# OpenAI Agents SDK (Python / TypeScript)
pip install deny-sh-openai-agents
npm i deny-sh-openai-agents @openai/agents

# Shared framework-agnostic core
npm i deny-sh-integrations-core

Exports are identical across adapters: denyVaultTool / deny_vault_tool, createVaultResolver / create_vault_resolver, isNarrowed / is_narrowed, DenyToolError, DenyLeakError. See the integrations pages for working agent loops.


1Password integration NEW

Push and pull encrypted control files to 1Password. Requires the op CLI to be installed and authenticated.

# Push control data to 1Password
deny-sh 1p push -l "Wallet backup" -c control.dat

# Pull control data from 1Password
deny-sh 1p pull -l "Wallet backup" -o control.dat

# List stored items
deny-sh 1p list

# Check connection status
deny-sh 1p status

Control data is stored as a Secure Note in your 1Password vault. The note contains base64-encoded control data and metadata (label, timestamp, ciphertext hash).


Bitwarden integration NEW

Push and pull encrypted control files to Bitwarden. Requires the bw CLI to be installed and authenticated.

# Push control data to Bitwarden
deny-sh bw push -l "Wallet backup" -c control.dat

# Pull control data from Bitwarden
deny-sh bw pull -l "Wallet backup" -o control.dat

# List stored items
deny-sh bw list

# Check connection status
deny-sh bw status

Handles BW_SESSION management automatically. Stored as Secure Note type 2 items.


Cloud backup NEW

Encrypted archive backups to Google Drive, Dropbox, S3, or local disk. Archives use a DENY_BACKUP_V1 envelope containing an encrypted inner bundle: version 0x03 is current (Argon2id t=3, m=64 MiB, p=1, 32-byte salt, 16-byte IV, AES-256-CTR); version 0x02 is legacy-compatible with the same KDF/cipher and a 16-byte salt.

Backup to local disk23 lines
# Configure provider and defaults
deny-sh backup config

# Backup to local disk
deny-sh backup push --provider local --dest ~/backups/

# Backup to Google Drive
deny-sh backup push --provider gdrive

# Backup to Dropbox
deny-sh backup push --provider dropbox

# Backup to S3
deny-sh backup push --provider s3

# Restore from backup
deny-sh backup pull --provider local --src ~/backups/deny-backup-2026-04-05.enc

# List backups
deny-sh backup list --provider local

# Toggle automatic backup after encrypt
deny-sh backup auto --enable

Backups include your vault, control files, and .deny/ directory. The archive is encrypted with your passwords before upload, so cloud providers never see your data.


Pricing

Free Dev $99/mo Pro $199/mo Scale $299/mo Agents Infrastructure $999/mo
Monthly API calls 500 10,000 100,000 250,000 1,000,000
Daily decoy calls 10 100 1,000 25,000 100,000
Encrypt / Decrypt / Deny
Control file vault 25 items 1,000 items 10,000 items 10,000 items 10,000 items
Audit log retention 90 days 90 days 365 days 365 days
Support GitHub Email Priority + SLA Email Priority + SLA
Annual pricing $950/yr (save 20%) $1,910/yr (save 20%) $2,990/yr (save 17%) Contact sales

Browser tools (encrypt, protect, verify) are free forever with no limits. CLI local operations are free forever too.

To upgrade: deny.sh/pricing or call POST /api/billing/checkout with your API key.


Commercial licensing

The deny.sh SDKs (TypeScript, Rust, Go, Python) are Apache License 2.0: zero copyleft and free for any use. The application layer (vault, MCP orchestration, hosted-API server, browser-tool source, and website source) is AGPL-3.0 with a commercial-licence path for proprietary self-hosting. See deny.sh/licensing.

Download the OpenAPI spec for Postman, code generation, or AI tooling.

Commercial application-layer licences start at $25,000/year, priced by deployment scale. Tell us what you're building and we'll scope the right licence.

hello@deny.sh for licensing enquiries. See Enterprise for dedicated infrastructure and SLA.


Errors

All errors return JSON with an error field and an appropriate HTTP status code:

{ "error": "Missing or invalid API key" }                              // 401
{ "error": "Monthly limit reached" }                                   // 429
{ "error": "Required: message, password1, password2" }                 // 400
{ "error": "Decryption failed. Check your passwords and control data." } // 400
{ "error": "plan must be one of: dev, pro, dev-annual, pro-annual" }  // 400
{ "error": "ciphertext and controlData must be valid hex strings" }    // 400
{ "error": "Message too large (max 10MB)" }                            // 400
{ "error": "Password too long (max 1024 chars)" }                     // 400

Rate limiting: The API enforces per-key monthly limits based on your plan tier, plus burst rate limiting to prevent abuse. Decoy-engine responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset; on 429, obey Retry-After before retrying.