Keep your secrets manager.
Add deniability on top.
Vault, Doppler, AWS Secrets Manager, 1Password: they all guard the bytes from outsiders. None of them have an answer for an authenticated agent that gets talked into reading its own context aloud. deny.sh is the layer that does. It sits on top of the secrets manager you already run. You don't migrate anything.
"We already have a secrets manager."
Good. Keep it. A secrets manager and deny.sh solve two different problems, and a serious AI-agent platform wants both. This page is the five-minute version of why, and the config to wire deny.sh in without touching what you already run.
Guards the bytes from outsiders
Encryption at rest, access policies, rotation, leases. It assumes the thing reading the secret is trustworthy. That assumption holds right up until your agent gets prompt-injected.
- Stops a stolen backup
- Stops an unauthorised employee
- Stops a leaked env file at rest
Guards you from your own agent
Keeps the real credential out of the model's context entirely. If an injection convinces the agent to dump what it holds, what walks out is a shape-correct decoy, and the access is paged within seconds.
- Stops a prompt-injected agent leaking the real key
- Serves a decoy the attacker can't distinguish
- Pages you the moment the decoy is read
The one-line version
- Your secrets manager answers "can an outsider steal the key?"
- deny.sh answers "what leaks when my own agent is hijacked?"
- Different threats. Different layers. You run them together.
The threats each layer actually covers.
Not a teardown of your secrets manager. A map of where its job ends and deny.sh begins.
| Threat | Secrets manager (Vault, Doppler, ASM, 1Password) |
deny.sh |
|---|---|---|
| Stolen backup / disk at rest | Covered | Not its job |
| Unauthorised human access | Covered | Not its job |
| Key rotation & leasing | Covered | Not its job |
| Agent reads its own context aloud (prompt injection) | No answer | Decoy leaks, real key never present |
| Forensic "prove which plaintext is real" | No answer | Mathematically indistinguishable |
| Tripwire when a credential is read | Audit log, after the fact | Paged in ~5s on decoy read |
A secrets manager that records access is great for the post-incident report. deny.sh changes what the attacker walks away with in the first place, and tells you it happened while it's happening.
deny.sh sits between your agent and the secret.
Your secrets manager keeps holding the real credential. deny.sh resolves it inside the tool boundary so the model never sees it. The two are complementary by design: BYOK even lets your existing AWS KMS key wrap every blob deny.sh stores.
Your secrets manager stays the system of record for the real credential, exactly as today.
deny.sh resolves it inside the tool boundary. The agent calls deny_decrypt(); the real key is fetched on the privileged side and used for the outbound call. It never enters the model's context.
An injection only reaches a decoy. Wrong control file in, shape-correct decoy out, audit chain records it, your alerting fires.
BYOK: your key wraps our blobs
Every server-stored ciphertext deny.sh holds is envelope-wrapped under a data key encrypted by your AWS KMS CMK. deny.sh calls only kms:GenerateDataKey and kms:Decrypt, assumes a role in your account with a non-optional sts:ExternalId guard, and goes dark the moment you revoke. Four-step BYOK walkthrough →
Drop deny.sh in next to what you run.
The credential keeps living in your secrets manager. You add one resolve call at the tool boundary. Here is the shape in the four places agent teams actually wire secrets.
Register the secret that holds the control payload; deny.sh reads it through your own ASM, in your account, on every resolve.
# keep the real key in ASM, as today
aws secretsmanager create-secret \
--name prod/stripe/live \
--secret-string "$STRIPE_KEY"
# register it with deny.sh as the custodian source
deny integrations secrets-manager add \
--arn arn:aws:secretsmanager:us-east-1:123…:secret:prod/stripe/live \
--tenant acme_42
Resolve inside execute(). The model receives the API result, never the key.
// inside your tool's execute()
const key = await deny.vaultGet(
{ tenant: "acme_42", name: "stripe/live" },
vaultPw1, vaultPw2
);
// real key used here, on the privileged side
const res = await fetch(stripeUrl, {
headers: { Authorization: `Bearer ${key}` }
});
return res.json(); // only this reaches the LLM
Your CI secret stays in GitHub. deny.sh resolves the runtime credential at job time so a compromised step leaks a decoy, not the live key.
# .github/workflows/deploy.yml
- name: Resolve runtime credential
run: |
deny decrypt \
--tenant acme_42 \
--name stripe/live \
--pw1 "$DENY_PW1" --pw2 "$DENY_PW2" \
> /dev/null # used in-process, never echoed
env:
DENY_PW1: ${{ secrets.DENY_PW1 }}
DENY_PW2: ${{ secrets.DENY_PW2 }}
Vault provider stays your source of truth. deny.sh wraps the value the agent runtime actually reads, so the file on disk is a decoy if it ever leaks.
# data source stays on your existing provider
data "vault_generic_secret" "stripe" {
path = "secret/prod/stripe"
}
# runtime reads through deny, not the raw value
resource "null_resource" "wrap" {
provisioner "local-exec" {
command = "deny vault store --tenant acme_42 --name stripe/live"
}
}
Snippets are illustrative shapes. Runnable, CI-typechecked examples for each framework live at github.com/deny-sh-crypto/deny-integrations.
Do I have to choose?
No. That's the entire point of this page.
Does deny.sh replace Vault / Doppler / AWS Secrets Manager?
No. Keep them. They remain your system of record and your at-rest protection. deny.sh adds a deniability layer in front of the agent so a prompt injection leaks a decoy instead of the real key. You run both.
Where does the real credential actually live?
Wherever it lives today. With the AWS Secrets Manager custodian integration it stays in your ASM, in your account, and deny.sh resolves through it. With BYOK, every blob deny.sh stores is wrapped under your AWS KMS key, so you can cut access at any time.
What does my agent's model see?
The result of the privileged call, never the credential. The key is resolved inside the tool boundary and used on the privileged side; the model only ever receives the API response.
What's the smallest way to try this alongside what I have?
Grab a free API key, wrap one credential, and wire one tool call. Your secrets manager doesn't change. If it doesn't earn its place, you've touched exactly one tool function.