Error Handling
Every failure code, what causes it, what you should do about it, and the status you return.
Every failure is { ok: false, code, error }. code is the stable thing to branch on — a frozen, published vocabulary that will only ever grow, because you branch on it in production. error is one sentence written to be shown to a human; do not parse it.
The same descriptions ship at runtime, so you can read them out of your own install rather than trusting this table:
import { VERIFY_ERROR_DESCRIPTIONS } from "@agentonboard/sdk";
console.warn(VERIFY_ERROR_DESCRIPTIONS.EXPIRED);The status you return
The framework bindings apply this mapping, and it is deliberate. If you call the core yourself, apply it yourself.
| Codes | Status | Why |
|---|---|---|
EXPIRED, INVALID_SIGNATURE, AUDIENCE_MISMATCH, ISSUER_MISMATCH, MALFORMED_TOKEN, UNKNOWN_KEY, KEY_SOURCE_UNAVAILABLE, MISSING_EMAIL | 401 | Uniform on purpose. A missing header, an expired token, a bad signature, the wrong audience, an unknown key, and a key endpoint that is down all mean one thing: "we do not know who you are." Splitting them into 400, 401, 419, and 503 hands anyone probing your endpoint a map of which part of a forgery attempt failed. |
ACCOUNT_REQUIRED | 403 | The identity is genuine — the token verified — and the account is not. Re-authenticating cannot create an account, so 401 sends the human in a loop and hides the one action that works: connect the account, then retry. |
CONFIG_ERROR, RESOLVER_ERROR | 500 | Nobody's request is wrong. A 401 tells the agent its token is bad and invites a re-authentication loop against a token that is fine, and it buries a server fault where nobody looks for 500s. |
MALFORMED_TOKEN and KEY_SOURCE_UNAVAILABLE are both 401
A token that is not a readable JWT, and a key endpoint that could not be reached, are both about the caller's token as far as the outside world is concerned. Answering 400 for one and 500 for the other would turn your endpoint into an oracle: 400 says "your forgery is malformed", 500 says "we cannot check right now".
The default body is { "code": "...", "error": "..." }, which publishes the code to an unauthenticated caller. That is intentional — it is what lets an agent act without a human interpreting it — and it is also an oracle. onFailure is the documented way to publish something else.
Token verification codes
VerifyErrorCode — nine codes, all reachable from verifyAgentToken and therefore from everything above it.
| Code | Status | Cause | What to do |
|---|---|---|---|
EXPIRED | 401 | exp is in the past beyond the 5-second tolerance. Tokens live 5 minutes, so this is the most common code you will see. | Answer 401. An agent should mint a new token and retry once. |
INVALID_SIGNATURE | 401 | The signature did not verify against a published key, or the token used an algorithm this SDK does not accept — alg: none, or HS256 signed with the public key. | Answer 401. The token was not minted by us. |
AUDIENCE_MISMATCH | 401 | aud is not your normalized audience, or the claim is absent. Almost always a token minted for a different service, or an auth.md advertising the wrong hostname. | Answer 401. Check the hostname in your auth.md against the audience in your source. |
ISSUER_MISMATCH | 401 | iss is not the resolved issuer, or the claim is absent. | Answer 401. If you run a staging or self-hosted issuer, set AON_ISSUER or pass issuer — this code is what you get when you configure nothing. |
MALFORMED_TOKEN | 401 | The token is not a readable compact JWS: garbage, an empty string, two segments, a header that is not JSON. Also a required claim that is absent or unusable. | Answer 401. The caller sent something that is not a token. |
UNKNOWN_KEY | 401 | The header carried no kid, or its kid matched no key in any set this process could obtain. The code deliberately does not say which. | Answer 401. If tokens that were valid a second ago now fail this, a rotation is in progress — see production. |
KEY_SOURCE_UNAVAILABLE | 401 | The key set could not be obtained: the fetch failed, the published set is not one the SDK can verify against, or it publishes more than one usable key for the token's kid. | Answer 401, and alert. This is our side, not the caller's. It only fires on a cold cache — a warm process falls back to its last good set. |
MISSING_EMAIL | 401 | The token verified, and carries no usable email claim. | Answer 401. The token is not one of ours. |
CONFIG_ERROR | 500 | The call itself is wrong: no audience, an audience that does not normalize to a hostname, a jwksUrl that will not parse, a clockToleranceSeconds that is not a non-negative number. | Fix the code you wrote. Nobody's request is wrong, so this is a 500. |
UNKNOWN_KEY is deliberately ambiguous
It does not tell you whether the key was revoked, rotated away, or never existed, nor whether the token was forged — and it does not tell you whether the set is merely out of date because the key endpoint was unreachable. The SDK cannot know, and a wrong confident diagnosis is worse than none. A forged kid and a rotation look the same from here; both are 401.
Account codes
AccountErrorCode — the nine above plus two, in their own union. ACCOUNT_REQUIRED is not in the core vocabulary precisely because it is not a verification failure: a core consumer's switch over the nine should not have an arm that implies an auth problem.
| Code | Status | Cause | What to do |
|---|---|---|---|
ACCOUNT_REQUIRED | 403 | The token verified. Your resolver returned null or undefined — this human has no account at your service. | Answer 403, tell the human which account to connect and where, then retry once. Retrying without the connection step will never succeed, and neither will sending them to register. |
RESOLVER_ERROR | 500 | Your resolveAccount threw. The thrown value is never echoed back — it is untrusted text that usually carries a connection string, and it would land in your error aggregator. | Log the detail inside your own resolver. Answer 500. Do not tell the caller to re-authenticate; the token is fine. |
Two resolver values that look like absences
return user?.id ?? null; // null → ACCOUNT_REQUIRED
return users.find((u) => u.email === email); // undefined → ACCOUNT_REQUIRED
return 0; // 0 → a real user id, resolves
return ""; // "" → a real user id, resolvesOnly null and undefined mean "no account". 0 and "" are user ids in the wild, and the SDK resolves them successfully rather than guessing your id scheme.
What to log
Log the code. Never the token, never the Authorization header, never the email.
// Good
console.warn(`Agent auth failed: ${result.code}`);
// Bad — a bearer credential, live for up to 5 minutes, into a log aggregator
console.warn(`Auth failed for ${token}: ${result.error}`);The jti from a successful verification is the field to correlate on in an audit log. It is not a replay defence: the SDK records no token ids, so a live token presented twice inside its 5-minute lifetime verifies both times. That is what a short-lived bearer token is.