The SDK has a deliberately small error surface. Only one method throws, and it throws exactly one type.
The throwing/non-throwing split
| Method | On failure |
|---|---|
check |
returns a deny Decision (never throws) |
can |
returns false (never throws) |
listResources |
returns [] (never throws) |
verifyToken |
rejects with TokenVerificationError |
check / can / listResources fold every failure into a safe return value — there is no exception to mishandle, so you can’t accidentally fail open. verifyToken is the exception because a token has no safe fallback “value”: the only correct signal for “this token can’t be trusted” is to reject. See ADR-2.
TokenVerificationError
Thrown by verifyToken when a token cannot be trusted.
import { TokenVerificationError } from '@padosoft/laravel-iam-node';
class TokenVerificationError extends Error {
readonly name = 'TokenVerificationError';
readonly reason: string; // short machine-readable reason
constructor(reason: string, options?: { cause?: unknown });
}
messageistoken verification failed: <reason>.reasonis the short cause, e.g.empty token,audience is required: …, ajosefailure message, orjwks: <detail>.cause(when present) is the underlying error — ajoseexception or a fetch/JWKS error — useful for logging.
When it’s thrown
| Situation | reason (illustrative) |
|---|---|
| Empty or non-string token | empty token |
| No audience configured or passed | audience is required: set \verify.audience` …` |
| JWKS unreachable / non-2xx / malformed | jwks: <detail> |
Bad signature, wrong iss/aud, expired/nbf |
the underlying jose message |
On a no-matching-key error (likely key rotation) the SDK refetches the JWKS once and retries before giving up — so a transient rotation doesn’t surface as an error. Any other failure rejects immediately.
Handling it
Treat any rejection as a hard deny. Don’t decode the unverified payload as a fallback.
try {
const claims = await iam.verifyToken(bearer, { audience: 'warehouse' });
// proceed only here — claims are trustworthy
} catch (err) {
if (err instanceof TokenVerificationError) {
logger.warn('token rejected', { reason: err.reason, cause: err.cause });
}
return res.status(401).end(); // fail-closed
}
A catch that logs and proceeds anyway lets unauthenticated requests through. Every TokenVerificationError must end in a 401/deny. See Fail-closed discipline.
Observing deny reasons (without branching on them)
The non-throwing methods leave a breadcrumb in the Decision. A synthetic deny carries a short reason in explanation:
Reason in explanation |
Cause |
|---|---|
no-subject |
the query had no subject.id |
transport |
network error / timeout / non-2xx / unparseable body |
invalid body |
the response wasn’t a JSON object |
check-threw |
the middleware’s check() call threw (e.g. circular context) |
Read these for logging and metrics only. Never branch authorization on them — “denied” and “couldn’t reach the PDP” must remain indistinguishable to your access logic. See Fail-closed by design.
Construction-time errors
The IamClient constructor throws plain Errors for misconfiguration, before any request:
IamClient: \baseUrl` is required— nobaseUrl` given.IamClient: no \fetch` available. Use Node 18+ or pass `fetch` in the config.— no globalfetch` and none injected.
These are programmer errors surfaced early, not runtime authorization failures.
Next steps
- Verifying tokens (JWKS) — the happy path.
- IamClient API — method-level behaviour.
- Fail-closed by design — why the split exists.