---
title: Threat model
description: Attacker goals, the mechanism that refuses each one, and the residual risk that remains.
---

Written as goals an attacker would have, because that is how the defences were
chosen. Every case has a corresponding test in `tests/security` — 49 of them. A
threat model without tests is a wish list.

## Impersonate an agent

| Goal | Refused by |
| --- | --- |
| Use a stolen agent id | Ids are not credentials. A signed assertion over the private key is required |
| Replay a captured assertion | Single-use nonce → `NONCE_REPLAYED` |
| Use an expired assertion | Short `exp`, plus a timestamp window → `TIMESTAMP_OUT_OF_WINDOW` |
| Replay an assertion against a different environment | `aud` is bound and compared exactly |
| Use a key after rotation | Generation counter → `IDENTITY_GENERATION_STALE` |
| Use a key after revocation | `AGENT_IDENTITY_REVOKED`. Terminal |
| Forge a passport | Platform Ed25519 signature over canonical JSON |
| Present a stale passport | The gateway re-resolves live authority; it never authorizes from a passport |

**Residual risk.** An attacker who obtains the private key *and* the agent id
can act as that agent until it is revoked. The mitigations are rotation,
short-lived sessions, and the fact that the agent's authority is bounded — so
what they get is the agent's bounded authority, not open access.

`veefore doctor` checks local key file permissions; a key readable by another
process on the host is a key that process can act with.

## Escalate authority

| Goal | Refused by |
| --- | --- |
| Delegate more than you hold | `DELEGATION_ESCALATION` at creation. Refused, not clamped |
| Widen an existing delegation | Same invariant on `PATCH` |
| Extend an expiry past the parent's | Same |
| Exploit a long chain | Depth bounded by `max_depth`; narrowest value wins at every dimension |
| Use a revoked parent's grant | Revocation cascades at every depth, immediately |
| Call a tool without the capability | `CAPABILITY_NOT_GRANTED` |
| Call a tool you hold the capability for but are not authorized for | `TOOL_NOT_AUTHORIZED` |
| Gain reach by registering a new tool | Registration grants nothing. Authorization is a separate step |
| Gain reach via MCP discovery | A discovered tool is unauthorized until a human authorizes it |
| Obtain a high-impact permission through a wildcard role | `HIGH_IMPACT_PERMISSIONS` must appear explicitly |
| Use an old session for an irreversible operation | `STEP_UP_PERMISSIONS` require fresh re-authentication |

**Residual risk.** An agent granted too much authority in the first place is
operating within its grant. Veefore bounds and records; it cannot know your
intent. Review delegations monthly and grant the narrowest ceiling that works.

## Steal a credential

| Goal | Refused by |
| --- | --- |
| Read a credential from the API | No endpoint returns material. Metadata only |
| Extract it from an agent's context | The agent never holds it. Only short-lived, scoped leases |
| Find it in logs | Redaction applied on **ingest**, not on output |
| Find it in the audit trail | Leases recorded by reference, never by value |
| Find it in an error message | Messages never contain secret values |
| Use a lease after rotation | Rotation invalidates every outstanding lease |
| Get a lease issued for a blocked action | Brokering is stage 10, after every refusal |
| Find one committed to source | `npm run quality:audit` fails the build |

**Residual risk.** A leaked lease is usable within its short lifetime for the
single action it was scoped to. A compromise of
`VEEFORE_DATA_ENCRYPTION_KEY` *and* the database would expose stored material —
which is why that key belongs in a secret manager and not in the database.

## Reach another tenant's data

| Goal | Refused by |
| --- | --- |
| Pass another organization's resource id | Repository layer requires a tenant scope → `TENANT_MISMATCH` |
| Exploit a query that forgot its scope | PostgreSQL row-level security, as an independent second layer |
| Use scope headers you have no access to | Scope is validated against the session |
| Enumerate ids | `TENANT_MISMATCH` rather than `404`, so the attempt is distinguishable in audit |

**Residual risk.** A bug in both layers simultaneously. The two are independent
by design — one in application code, one in the database — so a single mistake
is not sufficient. Enable `DATABASE_ENABLE_RLS` in production.
[→ Tenant isolation](/secure/tenant-isolation)

## Make the agent do something harmful

This is the category people care most about, and the honest answer is a
reduction in reward rather than prevention.

| Goal | Refused by |
| --- | --- |
| Inject a prompt to steal a credential | There is nothing to steal |
| Inject a prompt to exceed a ceiling | `AUTHORITY_EXCEEDED` at stage 6 |
| Inject a prompt to call an unauthorized tool | `TOOL_NOT_AUTHORIZED` at stage 5 |
| Inject a prompt to reach an unexpected host | `DESTINATION_BLOCKED` at the egress boundary |
| Inject a prompt to drop a table | Starter policies block `DELETE`, `DROP`, `TRUNCATE` |
| Talk the agent past a limit | Limits are server-side numbers the agent cannot see |
| Convince the agent it is authorized | Belief is irrelevant. The gateway decides |

:::security Veefore does not prevent prompt injection
It makes injection much less rewarding. The agent has no credential to
exfiltrate, and anything it is talked into attempting still runs the full
twelve-stage pipeline against the same authority and the same policy.

Authorization in a system prompt is not authorization. "You may only refund up
to ₹1,000" is an instruction, and instructions are negotiable. Put the ceiling
in a delegation. [→ The boundary](/secure/boundary)
:::

**Residual risk.** An agent authorized to do something harmful, doing it. The
value is bounded authority, a recorded decision and a known blast radius.
Narrow grants are the mitigation.

## Evade the audit trail

| Goal | Refused by |
| --- | --- |
| Alter one event | Keyed hash chain; the break is detectable and localised |
| Delete an event | Same |
| Act without being recorded | Every pipeline run writes an action record |
| Export evidence unnoticed | Export requires a reason and is itself an audit event |
| Shorten retention quietly | Step-up gated, `CRITICAL` audit event |
| Alter an exported file | Exports are watermarked and hashed |

**Residual risk.** An attacker with database write access *and*
`VEEFORE_AUDIT_CHAIN_SECRET` could rewrite the chain consistently. Keep the
secret out of the database, restrict write access, and ship events to an
append-only sink you control separately.
[→ Audit integrity](/secure/audit-integrity)

## Use availability as an attack

| Goal | Refused by |
| --- | --- |
| Make policy unreachable so everything is allowed | Fail-closed. `POLICY_SERVICE_UNAVAILABLE`, actions refused |
| Make identity unreachable | Same |
| Overwhelm the gateway to slip a request through | Rate limiting; refusal under pressure, not permission |
| Exhaust capacity through one broken downstream | Per-tool circuit breaker after 5 failures |
| Flood with requests | Velocity limits are themselves a policy decision |

:::security This is why fail-closed is not negotiable
Under fail-open, every denial-of-service becomes a privilege escalation — and
making a service unreachable is far easier than defeating its policy. The
cheapest attack would become an attack on availability rather than on
correctness. [→ Fail closed](/secure/fail-closed)
:::

**Residual risk.** Protected actions stop during an outage. The mitigation is
redundancy, not a bypass.

## Attack the egress path

| Goal | Refused by |
| --- | --- |
| Point a tool at an internal host | The tool's own `destination_allowlist`, checked again at egress |
| Use a redirect to reach a non-allowlisted host | `redirect: 'error'`. Redirects are refused, not followed |
| Exfiltrate over plaintext | HTTPS required unless the host is loopback |
| Hold a connection open to exhaust the gateway | 15-second egress timeout, plus the circuit breaker |

The redirect refusal is worth calling out: following a redirect is how an
allowlisted host becomes a request to a host that was never allowlisted. That is
server-side request forgery with extra steps, and the only safe handling is to
refuse.

## Attack a human session

| Goal | Refused by |
| --- | --- |
| Use a stolen session for an irreversible action | Step-up required at the moment of use |
| Use an idle session | `SESSION_IDLE_TIMEOUT_SECONDS` |
| Brute-force a password | `MAX_LOGIN_ATTEMPTS` and lockout |
| Reach the API from an unexpected network | IP allow/deny rules → `NETWORK_RESTRICTED` |
| Approve from an unattended browser | `requireStepUp` on the approval rule |

## Out of scope for this model

**Physical access to the database host.** Standard infrastructure security.

**A compromised Veefore deployment.** If an attacker controls the gateway
process, they control authorization. Protect it as you would any critical
service.

**Your downstream's own security.** Veefore brokering a credential does not make
an unauthenticated API safe.

**Model behaviour.** Veefore bounds what an agent may do. It does not evaluate
whether a model's reasoning is sound.

## Verifying this page

```bash
npm run test:security   # 49 cases
npm run test:chaos      # 13 cases, fail-closed and recovery
npm run smoke:gateway   # replay refusal, enforcement, revocation, over real HTTP
```

## Related

:::cards

:::card The boundary | /secure/boundary
Where authorization happens.
:::

:::card Fail closed | /secure/fail-closed
Availability as an attack surface.
:::

:::card Secrets | /secure/secrets
Every place a secret could surface.
:::

:::card Responsible disclosure | /secure/disclosure
Reporting something this page missed.
:::

:::
