# Security and data handling

## Where decisions run

Heedeo runs as middleware inside your own app. Verification, principal lookup, counting and the verdict all happen there, before the request reaches your route.

## What leaves your servers

Only decision metadata goes to the Heedeo dashboard:

- route
- verdict
- agent
- reason
- rule line

Request bodies are never sent. Reasons never contain principal identifiers: they say "This person is at 7/5 per day on /signup", not who the person is. You can turn the dashboard reporting off.

## Signature verification

What the verifier behind `GET https://heedeo.com/api/visitor` checks, in order:

- `Signature-Input` and `Signature` parse as RFC 8941 structured fields, and one signature has `tag="web-bot-auth"`.
- `Signature-Agent` names an origin that passes the SSRF rules below.
- `alg`, if given, is `ed25519`.
- The signature covers `@authority` and `signature-agent`.
- `created` and `expires` are both present; `created` is not more than 60 seconds in the future; `expires` has not passed (60 seconds of clock skew allowed); `expires` is after `created`; the lifetime is at most 24 hours.
- The `keyid` matches the RFC 7638 JWK SHA-256 thumbprint of an Ed25519 (OKP) key in the operator's `/.well-known/http-message-signatures-directory`.
- The Ed25519 signature over the RFC 9421 signature base verifies, using WebCrypto.
- The signature has not been seen before (replay check, below).
- The operator is on `trustedOperators`.

Result fields: `present`, `valid` (the signature checks out), `trusted` (the operator is on the list), `verified` (both), `agentOrigin`, `keyId`, `reason`. A valid signature from an untrusted operator returns `valid: true, trusted: false, verified: false` ("Signed, untrusted") and counts as unknown.

```bash
# Claims to be ChatGPT, with a made-up key: the real directory is fetched, and the key isn't in it
NOW=$(date +%s)
curl https://heedeo.com/api/visitor \
  -H 'Signature-Agent: "https://chatgpt.com"' \
  -H "Signature-Input: sig1=(\"@authority\" \"signature-agent\");created=$NOW;expires=$((NOW+60));keyid=\"test\";alg=\"ed25519\";tag=\"web-bot-auth\"" \
  -H 'Signature: sig1=:AAAA:'
```

Real output:

```json
{"kind":"agent","name":"ChatGPT agent","signed":true,"verified":false,"verifiedFor":null,"summary":"Signature headers present but not verified (key not found in directory). Classified from the user agent only.","signature":{"present":true,"valid":false,"trusted":false,"verified":false,"agentOrigin":"https://chatgpt.com","keyId":"test","reason":"key not found in directory"}}
```

## Replay protection

A signature that verified once is refused if presented again before it expires, with reason `"replayed signature"`. The cache is keyed on `keyid` plus the signature bytes, and kept until `expires` plus 60 seconds.

Stated plainly: on heedeo.com this cache is in memory, per Workers isolate. It is not global. A replay that lands on a different or freshly started isolate is not caught, and when the cache is full its oldest entries are dropped. How replay state is shared in your deployment is agreed during onboarding.

## SSRF-safe key fetching

Fetching a key directory means making an outbound request to an origin named by the caller, so the fetch is restricted:

- https only, port 443 only, no credentials in the URL.
- A hostname, not an IP literal. Single-label names and names under `localhost`, `local`, `internal`, `intranet`, `lan`, `home`, `corp`, `localdomain`, `home.arpa`, `test`, `invalid` and `example` are refused.
- Only `/.well-known/http-message-signatures-directory` on that origin is fetched.
- Redirects are not followed. The fetch times out after 2 seconds. Directories over 64 KiB are refused.
- Directories are cached for 10 minutes. A failed fetch is cached for 60 seconds.

Cloudflare Workers can't resolve DNS before a fetch, so a public name that resolves to a private address is not caught by these checks. heedeo.com also runs with Cloudflare's `global_fetch_strictly_public` compatibility flag, which keeps Worker fetches on the public internet.

## Failure modes

| Situation | Result |
|---|---|
| No signature headers | not verified: detected if the user agent is recognised, otherwise unknown |
| Signature malformed, expired or not matching | not verified |
| Key directory unreachable, timed out, redirected, too large or not JSON | not verified (treated as unsigned) |
| Key not in the directory | not verified |
| Signature replayed | not verified |
| Valid signature, operator not trusted | not verified ("Signed, untrusted") |

Every failure means unknown to your rules, never verified. What unknown agents may do is up to your rules: on the demo policy, a passkey challenge on `/signup` and a block on `/checkout`.

Where per-principal counters live, and whether a counter outage fails open or closed, are agreed during onboarding.

## Limits worth knowing

- A passkey does not prove a unique human, and neither does an email. Linking one person's several accounts relies on signed principal tokens when present, plus your own signals (payment card, verified email domain, device), set up at onboarding.
- Agents that don't sign are never verified. Recognising one by its user agent makes it detected, which rules treat as unknown.

See also: https://heedeo.com/docs/how-it-works.md and, for what heedeo.com itself collects, https://heedeo.com/privacy.md.

---

Docs index: https://heedeo.com/docs.md. How to verify our claims: https://heedeo.com/trust.md. Book a call: https://cal.com/heedeo/30min

Updated: 2026-10-02
