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:

CheckReject when
Actionact is not the action this route expects, for example signup
Sessionsid is not the visitor session you expect, if you track one
Trustintegrity.quarantined is true, or integrity.trust is below 0.4
Conductbehavior.label is rogue, or authorization.decision is denied
Recommendationrec is deny, challenge, step_up or rate_limit
Classcls is not human
FreshnessFor a payment, iat is more than 60 seconds old
ReplayYou 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
sidSession id
clshuman, bot or agent
pProbability of each class
agent{ family, verified } when it is an agent
recRecommendation: allow, tag, challenge, step_up, rate_limit or deny
actThe action the token was made for
profSite profile
siteSite id (st_…)
pkThe 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
lvlCaller 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
jtiUnique token id, for replay checks
iat, expIssued and expiry times, unix seconds. Tokens live 10 minutes
vModel version

What behavior, authorization and integrity mean: Verdict.

Errors

WhereCodeMeans
/v1/verifymalformed, unsupported_alg, unknown_kid, bad_signatureNot a valid Double Agent token
/v1/verifyexpired, not_yet_validOutside its time window. Get a new token in the browser
/v1/evaluate401 unauthorizedMissing or wrong secret key
/v1/evaluate400 invalid_observation, 400 invalid_requestA missing or badly shaped field
/v1/evaluate400 invalid_caller, 400 invalid_delegationBad caller fields. protocol: "pap" is refused until PAP is published
/v1/evaluate403 origin_not_allowedrequest.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.