File DA-041 View as Markdown
Verify tokens on your server
Double Agent signs its verdict in a short token, so a bot cannot fake being human in the browser. This page shows how to check that token on your server, decide whether to let the request through, and report how the action ended. Use it to guard a sign-up, login, checkout or API route. Everything here is plain HTTP and standard JWTs, so it works in any language.
Before you start: a public key on the snippet, because tokens don't work keyless (Capabilities). A secret key (sk_…) only for reporting outcomes and reading data.
1. Send a token from the browser
protect() adds a hidden da_token field to a form. For your own fetch calls, get a token and send it in a header:
const token = await doubleagent.getToken('signup');
await fetch('/api/signup', { method: 'POST', headers: { 'x-doubleagent-token': token }, body });
A token lives 10 minutes and names the action it was made for.
2. Verify it
The token is a JWT signed with EdDSA (Ed25519). Verify it offline with any JWT library against Double Agent's public keys. In Node with jose:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://api.doubleagent.so/.well-known/jwks.json'));
const { payload: claims } = await jwtVerify(token, keys, { algorithms: ['EdDSA'], clockTolerance: 30 });
if (claims.site !== 'st_…') throw new Error('token from another site');
The library caches the keys and checks exp. Always check site yourself: it is your site id (st_…). Older tokens may carry your public key there instead, so a pk or site equal to your pk_… is fine too.
No JWT library? Ask the API, one request per token:
const res = await fetch('https://api.doubleagent.so/v1/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
});
const { valid, claims, error } = await res.json(); // valid: false comes with error.code
/v1/verify checks the signature and expiry, not the site, so check claims.site the same way.
3. Decide
Double Agent never blocks. Your server decides. For a route that only people should use, let the request through when none of these hold:
| Check | Reject when |
|---|---|
| Action | act is not the action this route expects, for example signup |
| Session | sid is not the visitor session you expect, if you track one |
| Trust | integrity.quarantined is true, or integrity.trust is below 0.4 |
| Conduct | behavior.label is rogue, or authorization.decision is denied |
| Recommendation | rec is deny, challenge, step_up or rate_limit |
| Class | cls is not human |
| Freshness | For a payment, iat is more than 60 seconds old |
| Replay | You have seen this jti before. Keep each jti until its exp |
To let good agents through too, also accept cls = agent when agent.verified is true, behavior.label is friendly and authorization.decision is allowed. Answer 401 for a missing or bad token and 403 for a refusal.
4. Report what happened
Tell Double Agent how the action ended. It learns from it (a burst of failed logins becomes a credential attack in Threats), and you get a fresh verdict and token back:
const res = await fetch('https://api.doubleagent.so/v1/evaluate', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.DOUBLEAGENT_SECRET_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
sid: claims.sid,
subject: user.id, // your stable id for the account, never an email or IP
observationId: crypto.randomUUID(), // reuse it when you retry
action: 'login',
outcome: 'auth_failed', // ok, auth_failed, action_denied or confirmed_trap
request: { url: 'https://example.com/login', method: 'POST', headers: { 'user-agent': userAgent } },
}),
});
const { verdict, token, activity, policyMode } = await res.json();
subject and observationId are 8 to 128 letters, digits, _ or -. The request URL must be on one of the site's verified domains (localhost works with a test key), with at most 24 lowercase headers.
If an AI agent acted for your customer, add who it was and under which grant. Double Agent then gives the call a verification level, and sends a webhook when it goes outside its grant:
{
"actor_kind": "agent",
"delegation": { "protocol": "oauth", "client_id": "chatgpt", "scopes": ["orders:read"], "access": "read", "grant_id": "g_8f2…" },
"scope_required": ["orders:write"]
}
The answer then has caller, for example { "level": "delegated", "in_grant": "no", … }, and the new token carries lvl and grant. Every field: REST API. What the levels mean: Callers.
5. Verify webhooks
Debrief and scope-violation webhooks are signed with your webhook secret. The receive code is on Debrief.
Token claims
| Claim | |
|---|---|
sid | Session id |
cls | human, bot or agent |
p | Probability of each class |
agent | { family, verified } when it is an agent |
rec | Recommendation: allow, tag, challenge, step_up, rate_limit or deny |
act | The action the token was made for |
prof | Site profile |
site | Site id (st_…) |
pk | The public key the token was requested with |
behavior | { label, risk, source, reasons }: friendly, neutral or rogue |
authorization | { decision }: allowed, denied or unknown, from your agent behaviour policy |
integrity | { trust, quarantined, reasons }: how far the evidence can be trusted |
lvl | Caller level, on tokens from /v1/evaluate with caller fields |
grant | { id, protocol, access }, on tokens from /v1/evaluate with a delegation. id is the grant id hash |
jti | Unique token id, for replay checks |
iat, exp | Issued and expiry times, unix seconds. Tokens live 10 minutes |
v | Model version |
What behavior, authorization and integrity mean: Verdict.
Errors
| Where | Code | Means |
|---|---|---|
/v1/verify | malformed, unsupported_alg, unknown_kid, bad_signature | Not a valid Double Agent token |
/v1/verify | expired, not_yet_valid | Outside its time window. Get a new token in the browser |
/v1/evaluate | 401 unauthorized | Missing or wrong secret key |
/v1/evaluate | 400 invalid_observation, 400 invalid_request | A missing or badly shaped field |
/v1/evaluate | 400 invalid_caller, 400 invalid_delegation | Bad caller fields. protocol: "pap" is refused until PAP is published |
/v1/evaluate | 403 origin_not_allowed | request.url is not on a verified domain |
The Node package
@doubleagent-so/node wraps these steps (verifyToken, a requireHuman route guard, evaluate, verifyWebhook). It is not on npm yet. Until it is, use the code on this page.
Next: Debrief for webhooks, or Callers for what /v1/evaluate shows.