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"
}
| Parameter | Type | Description |
|---|---|---|
| message required | string | Plaintext to encrypt. Max 10MB. |
| password1 required | string | Primary password. Max 1024 chars. |
| password2 required | string | Secondary password. Max 1024 chars. |
| controlDataHex | string | Optional 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"
}
| Parameter | Type | Description |
|---|---|---|
| ciphertext required | string | Hex-encoded ciphertext. |
| controlData required | string | Hex-encoded control data. |
| password1 required | string | Primary password. |
| password2 required | string | Secondary 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
}| Parameter | Type | Description |
|---|---|---|
| ciphertext required | string | Hex-encoded ciphertext from a previous encrypt call. |
| password1 required | string | Primary password (same as used for encryption). |
| password2 required | string | Secondary password (same as used for encryption). |
| fakeMessage required | string | The 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.
| Tier | Daily decoy calls |
|---|---|
| Free | 10 |
| Dev | 100 |
| Pro | 1,000 |
| Scale | 25,000 |
| Agents Infrastructure | 100,000 |
| Enterprise | 1,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"
}
| Parameter | Type | Description |
|---|---|---|
| size required | number | Byte 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:
- Create a symmetric AES-256 CMK in AWS KMS in the region you want.
- Create an IAM role in your account with a trust policy naming deny.sh's account as principal, an
sts:ExternalIdcondition with your tenant ID, and permissionskms:GenerateDataKey+kms:Decryptscoped to your CMK ARN. Mirror the same allow in the CMK's key policy. - Paste both ARNs plus the region into /dashboard/byok.
- 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
| state | meaning |
|---|---|
pending_verification | Config registered, verify roundtrip in flight. |
active | Verify succeeded. New writes envelope-wrap. Reads unwrap on demand. |
failed | Verify failed. See failure_reason for the error code. Existing rows unaffected. |
revoked | Config disabled. New writes stored unwrapped. Reads of wrapped historical rows return BYOK_UNAVAILABLE until re-registered. |
Failure-mode reference
| code | meaning | retryable |
|---|---|---|
BYOK_ROLE_NOT_ASSUMED | STS AssumeRole denied. Trust policy missing or condition mismatch. | no |
BYOK_KEY_NOT_FOUND | CMK ARN doesn't resolve. Wrong account, wrong region, or deleted. | no |
BYOK_KEY_DISABLED | CMK exists but is disabled or pending deletion. | no |
BYOK_REGION_MISMATCH | CMK ARN's region differs from the registered region. | no |
BYOK_PERMISSION_DENIED | Role assumed but kms:GenerateDataKey or kms:Decrypt missing. | no |
BYOK_UNAVAILABLE | Transient AWS error or 5xx. Safe to retry. | yes |
BYOK_ENVELOPE_CORRUPT | Stored 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_type | fires when |
|---|---|
byok.config.created | Customer registers a CMK + role. |
byok.config.verified | Roundtrip succeeds; state moves to active. |
byok.config.rotated | Customer swaps the CMK ARN in place. |
byok.config.revoked | Customer revokes; existing envelopes go dark. |
byok.envelope.created | A vault write was wrapped. |
byok.envelope.unwrapped | A vault read was unwrapped. |
byok.kms.failure | Any 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
.datdocuments - 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 --enableBackups 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 | Priority + SLA | 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.