AgentOnboard Docs

Verifying a Token

verifyAgentToken in full — every option, the audience rules, what it returns, and the one documented exception to its non-throwing contract.

verifyAgentToken is the public contract. Everything else in this package — the account wrapper, the three framework bindings — is a convenience layer on top of it, and a partner who depends on the core alone is insulated from all of them.

It checks a token, against the keys we publish, and returns either the verified identity or a code you can branch on. It does not call a database, and it does not call us.

import { verifyAgentToken } from "@agentonboard/sdk";

const result = await verifyAgentToken({
  token,        // the raw JWT, with or without a `Bearer ` prefix
  audience: "notes.com",
});

What it checks

CheckRuleFixed?
AlgorithmRS256 only. The token's own header never chooses it; alg: none and HS256 confusion are rejected as INVALID_SIGNATURE.yes
SignatureVerified against a public key from the JWKS, RSA 2048 bits or larger.yes
expEnforced, with a clock-skew tolerance of 5 seconds.option
audMust equal your audience exactly. See audience below.yes
issMust equal the resolved issuer.option
Required claimsexp, jti, sub, email must all be present and usable.yes
Key selectionThe header must carry a kid that names a key in the set. A token with no kid is UNKNOWN_KEY.yes

The token is a bearer credential with a 5-minute lifetime. That is the whole of its replay story: the SDK records no token ids, so a token presented twice inside its window verifies both times. Treat it as a bearer token — TLS only, never logged.


Options

interface VerifyAgentTokenOptions {
  token: string;                  // required
  audience: string;               // required
  issuer?: string;
  jwksUrl?: string;
  jwks?: JSONWebKeySet;
  fetchJwks?: (url: string) => Promise<unknown>;
  clockToleranceSeconds?: number;
}

Prop

Type

The option is clockToleranceSeconds

There is no clockSkewSeconds. Passing a name that does not exist is a type error under TypeScript, and under plain JavaScript it is silently ignored — you get the 5-second default and no warning that your value was discarded.

AON_ISSUER

AON_ISSUER is the only environment variable the SDK reads, and it is the default for issuer. It is compared against the token's iss claim and it is the host the keys are fetched from.

Set it if you are not verifying against production

A partner that configures nothing against a non-production issuer — a staging deployment, a self-hosted Identity Engine — rejects a perfectly good token with ISSUER_MISMATCH, and the key fetch goes to the production JWKS. That is the intended behaviour, not a bug. Set AON_ISSUER in the environment, or pass issuer explicitly.

jwks and fetchJwks

jwks takes a parsed key set and bypasses the URL entirely. It is how an air-gapped or self-hosted issuer works, and how a test supplies keys with no network at all. It wins over fetchJwks when both are set: supplying keys is the stronger statement, and it is the only path that touches no network.

fetchJwks replaces the host runtime's fetch for the key-set request — a pinned egress proxy, a cached copy, a test with no network. It is called with the same URL that would have been fetched and is expected to resolve to a parsed JWKS. The shape check still applies, and the last-known-good fallback still applies.

Log inside your own retriever — the thrown value is never reported onward

A retriever that throws reaches the caller as KEY_SOURCE_UNAVAILABLE carrying a message the SDK wrote. What you threw is discarded. So a postgres://user:pw@db.internal:5432 or a URL with userinfo in it stays yours to log, instead of landing in your error aggregator from a 401 body the caller can read. Log it in the retriever, then rethrow.

const result = await verifyAgentToken({
  token,
  audience: "notes.com",
  fetchJwks: async (url) => {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
      if (!res.ok) throw new Error(`JWKS endpoint responded ${res.status}`);
      return await res.json();
    } catch (err) {
      // The SDK will not publish this. Log it where you can see it.
      logger.error({ err, url }, "key set retrieval failed");
      throw err;
    }
  },
});

Audience

Your audience option is normalized to a hostname: trimmed, lowercased, and stripped of any scheme, path, and port. localhost and 127.0.0.1 keep their port, because a local deployment's port is part of its identity.

You passNormalized to
notes.comnotes.com
https://api.notes.com/v1/api.notes.com
http://notes.com:8080/datanotes.com
MYSERVICE.XYZ/pathmyservice.xyz
https://user:pw@notes.com:8443/xnotes.com
localhost:3000localhost:3000
notes.com.evil.comnotes.com.evil.com

The token's aud claim is then compared to that normalized value exactly, and case-sensitively. Only your option is normalized; the claim is taken verbatim.

The property that matters

Because normalization collapses scheme, path and port, https://api.notes.com/v1 and api.notes.com are the same audience. What is never allowed is a prefix or suffix match: notes.com.evil.com does not match notes.com, and a token minted for one is rejected by the other with AUDIENCE_MISMATCH. That is the case a cross-service replay depends on, and it is pinned by a named conformance vector.

The same normalization runs on the minting side, so the string a human's agent passes to aon token get and the audience you write in your source agree as long as they point at the same host. Write the bare hostname you serve on. If your API lives on a subdomain, that subdomain is a separate audience with its own auth.md.

An audience that does not normalize to a hostname — "", "/" — is CONFIG_ERROR, not a mismatch. It is your bug, not the caller's.


What it returns

A discriminated union on ok. No cast, no in, no optional chaining:

type VerifyAgentTokenResult =
  | { ok: true;  email: string; sub: string; userId?: string; expiresAt: number; jti: string; audience: string }
  | { ok: false; code: VerifyErrorCode; error: string };
FieldMeaning
emailThe email claim verbatim — not lowercased, not trimmed, not normalized. See below.
subThe sub claim. Our issuer sets it to the same verified email.
userIdDeprecated alias for sub. It carries the subject claim, not our internal id. Use sub or email.
expiresAtUnix epoch seconds. Useful for a cache with a margin.
jtiThe token's unique id. Returned for correlation in your audit log.
audienceThe normalized audience the token was checked against.

email is the claim verbatim, and the case policy is yours

email is exactly what the claim holds, including any surrounding whitespace or mixed case. normalizeEmail is exported for convenience — it trims and lowercases — but the decision of which form your database column holds is yours. Normalize once, inside your own resolver, rather than comparing an unnormalized claim against a normalized column in a query.


The non-throwing contract

Every verification failure returns { ok: false, code, error }. There is no path where an error yields a pass, and no path where a bad key set, a slow endpoint, a forged token, or a misconfigured call throws past the return.

// No try/catch needed around the call itself.
const result = await verifyAgentToken({ token, audience: "notes.com" });

The one documented exception

The contract covers verification outcomes, not being called with no argument. verifyAgentToken(undefined) throws a TypeError before verification starts, because destructuring its options is the first thing it does. Pass an options object.

Two things the contract deliberately does not do:

  • It does not report the reason a key could not be obtained. Anything a retriever throws is discarded and replaced with a message the SDK wrote. Log inside your own retriever.
  • It does not cache verification results. A cached "this token is valid" is how a revoked or rotated token keeps working. Only key sets are cached, and only for an hour.

Next

On this page