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
| Check | Rule | Fixed? |
|---|---|---|
| Algorithm | RS256 only. The token's own header never chooses it; alg: none and HS256 confusion are rejected as INVALID_SIGNATURE. | yes |
| Signature | Verified against a public key from the JWKS, RSA 2048 bits or larger. | yes |
exp | Enforced, with a clock-skew tolerance of 5 seconds. | option |
aud | Must equal your audience exactly. See audience below. | yes |
iss | Must equal the resolved issuer. | option |
| Required claims | exp, jti, sub, email must all be present and usable. | yes |
| Key selection | The 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 pass | Normalized to |
|---|---|
notes.com | notes.com |
https://api.notes.com/v1/ | api.notes.com |
http://notes.com:8080/data | notes.com |
MYSERVICE.XYZ/path | myservice.xyz |
https://user:pw@notes.com:8443/x | notes.com |
localhost:3000 | localhost:3000 |
notes.com.evil.com | notes.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 };| Field | Meaning |
|---|---|
email | The email claim verbatim — not lowercased, not trimmed, not normalized. See below. |
sub | The sub claim. Our issuer sets it to the same verified email. |
userId | Deprecated alias for sub. It carries the subject claim, not our internal id. Use sub or email. |
expiresAt | Unix epoch seconds. Useful for a cache with a margin. |
jti | The token's unique id. Returned for correlation in your audit log. |
audience | The 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.