@spfn/signing
One signer interface. Three places a private key can live. One token format.
Signs EdDSA and ES256. Verifies those two and RS256.
SPFN signs four different things — client request tokens, session envelopes, one-time SSE tokens, and now bridge tokens read inside untrusted tenant containers — and until this package they were signed three different ways, each of which assumed the private key was a string sitting in the process. That assumption does not survive contact with a cloud KMS.
@spfn/signing is the Signer interface and the providers behind it. The key may be in
this process, in Google Cloud KMS, or in AWS KMS; the caller's code does not change, and
the token on the wire does not change.
import { createSigner, timeClaims } from '@spfn/signing';
const signer = await createSigner({
provider: 'local',
kid: 'bridge-2026-08',
alg: 'EdDSA', // the default; omit it and you get this
privateKey: { env: 'SPFN_BRIDGE_SIGNING_KEY' },
});
const token = await signer.sign({ sub: tenantId, ...timeClaims({ ttlSec: 300 }) });
import { verifyJws } from '@spfn/signing/verify';
const result = verifyJws(token, process.env.SPFN_BRIDGE_PUBLIC_KEYS!, { maxAgeSec: 300 });
if (!result.ok)
{
return deny(result.reason); // 'expired' | 'bad-signature' | 'unknown-kid' | …
}
Installation
pnpm add @spfn/signing
Nothing else. The KMS SDKs are optional peer dependencies — install one only if you use that provider:
pnpm add @google-cloud/kms # for provider: 'gcp-kms'
pnpm add @aws-sdk/client-kms # for provider: 'aws-kms'
The two entry points
| Import | Depends on | Use it when |
|---|---|---|
@spfn/signing |
node:crypto, plus one KMS SDK if you name that provider |
you issue tokens |
@spfn/signing/verify |
node:crypto. Nothing else, ever |
you check them |
The split is not tidiness. The code that verifies a bridge token runs inside a tenant
container, and every dependency it carries is a dependency that has to be shipped there
and trusted there. @spfn/signing/verify is a single file with one import, and a test
copies the built artifact somewhere with no node_modules above it and imports it there.
verifyJws() never throws on token input. A malformed token is a verdict, not an
exception — an attacker chooses the bytes, so the bytes cannot choose the control flow.
Providers
| Provider | Key lives in | EdDSA (default) | ES256 | Construction |
|---|---|---|---|---|
local |
this process (env var, file, or a KeyObject) |
yes | yes | synchronous, no I/O beyond the file |
gcp-kms |
Cloud KMS | yes — EC_SIGN_ED25519 |
yes — EC_SIGN_P256_SHA256 |
reads the key version's algorithm and public key |
aws-kms |
AWS KMS | yes — ECC_NIST_EDWARDS25519 |
yes — ECC_NIST_P256 |
reads the key's algorithm and public key |
EdDSA (Ed25519) is the default. It is the shorter key, the shorter signature, and the signature scheme with no per-signature nonce to get wrong. ES256 stays fully supported and is the right choice where something downstream only speaks ECDSA.
sign() returns a promise on every provider, local included. A KMS round trip is a
network call, and an interface that changes shape depending on where the key lives is not
one interface.
On both KMS providers the algorithm is the key's, not the caller's: construction reads the
key and takes the algorithm from its spec. Passing alg says what you expected, and a key
that disagrees is an error at construction rather than a surprise at first use.
Both providers also make the response agree with itself. Cloud KMS returns an algorithm
enum beside the PEM, and AWS returns a KeySpec beside the SPKI; neither API ties the
label to the key. When they disagree, construction fails and names both — signing with the
label while publishing the key would ask KMS for one algorithm and hand every verifier a
key for the other, and every token would then fail to verify for no visible reason.
AWS KMS signs the message, not a digest
Both algorithms are sent with MessageType: RAW, which is what caps a signing input at
4 KiB. For ES256 that keeps the SHA-256 on the server side; for Ed25519 it is not a choice
at all — ED25519_SHA_512 is PureEdDSA, which signs the message itself. The pre-hashed
ED25519_PH_SHA_512 is a different algorithm producing a different signature, and this
package does not use it. An ED25519_SHA_512 signature comes back as 64 raw bytes and is
already JOSE's form; only ECDSA's DER needs converting.
sops, Secret Manager and Vault are not providers
They are delivery. Each one puts a value into an environment variable or a file before
the process starts, and local reads it from there. Nothing in this package decrypts
anything at runtime, holds a secret-manager client, or refreshes a lease. A provider is a
place a private key lives and signs; a secret manager is a place a value is stored.
# sops-encrypted, decrypted by your deployment, never by this package
SPFN_BRIDGE_SIGNING_KEY: <base64url of the 32-byte seed>
Key formats
A verifier is often a plain script with one environment variable, so the public key format is a string a person can paste:
public-keys := entry ("," entry)*
entry := kid ":" base64url(key)
kid := [A-Za-z0-9._-]{1,128}
key := 32 raw bytes — Ed25519 public key (EdDSA)
| 65 bytes, 0x04 || X || Y — SEC1 uncompressed P-256 (ES256)
| SPKI DER — either algorithm
bridge-2026-07:BFFcPW6545a5BNP-yn9U_c0…,bridge-2026-08:Kay64UG8yvCyLhqU000LxzY…
base64url is strict and canonical: no padding, no +, no /, and the unused bits in
the final character must be zero. Buffer.from(text, 'base64url') silently skips what it
cannot read and ignores those bits, which would let four different strings decode to the
same 32-byte key and sixteen to the same 64-byte signature. So every segment is matched
against the alphabet, decoded, re-encoded, and compared to what arrived — the encoding is
one-to-one or the input is refused.
A 33-byte compressed point is refused. By length alone it is indistinguishable from a
mistyped Ed25519 key, and node:crypto will not import a bare point anyway. Wrap it in
SPKI DER.
Private key material for local may be a PKCS#8 PEM, PKCS#8 DER, or 32 raw bytes — an
Ed25519 seed or a P-256 private scalar. The scalar is the reason alg is worth stating
rather than deriving: 32 bytes is both, and only you know which. Omitted, it is EdDSA,
and a key that turns out to be the other one is an error at construction.
Tokens
JWS compact serialization (RFC 7515): header.payload.signature.
algisEdDSAorES256for a token this package signs, and may also beRS256for one it only verifies (RSA keys). Nothing else, andnoneis simply analgno key has.kidis required in the protected header.typandctyare optional, and each is a string when present — a header that carries either as a number or an object ismalformed.- A duplicate JSON member, in the header or the payload, at any depth, is
malformed. RFC 8259 leaves them undefined andJSON.parsekeeps the last one, so{"kid":"a","kid":"b"}reads as one token here and another elsewhere. Names are compared unescaped, so"a"and"\u0061"are the same member. critis rejected: this verifier implements no extensions, so RFC 7515 §4.1.11 says it must refuse rather than guess.sign()refuses to write one for the same reason — a token your own verifier callsmalformedis a bug to catch at the signer.- The payload is yours.
timeClaims()andwithTimeClaims()will buildiat/exp/nbffor you; nothing requires you to use them. - ES256 signatures are JOSE
r || s, 64 bytes, on every provider — never DER. The DER a KMS returns is parsed strictly on the way in: exact lengths, no trailing bytes, minimally encoded non-negative integers, nothing wider than the curve. There is one encoding of a signature, so there is one token.
The algorithm comes from the key, not the token
kid selects the key. That key's algorithm is the algorithm. The header's alg is then
checked for equality with it and used for nothing else. This is what makes algorithm
confusion — including alg: "none" — a alg-mismatch rather than a decision. The same
holds across key families: an RS256 header naming an Ed25519 key, or an EdDSA header
naming an RSA key, is alg-mismatch before any signature is looked at.
Verification
verifyJws(token, keys, {
now?, clockSkewSec = 30, maxAgeSec?,
issuedWithinSec?, issuer?, audience?,
})
keys is the kid:key,… string, a PublicKeyEntry, an array of them, a Map, a
KeyRing's publicKeys(), or — for keys you did not issue — an RsaPublicKeyEntry, a JWK
Set, or the keys of a parsed one.
reason |
Means |
|---|---|
malformed |
not three canonical base64url segments, or no JSON header with a kid, or a crit header, or a payload that is not a JSON object |
invalid-claims |
the signature is yours, but a time claim is present and is not a finite number, or iat is after exp |
unknown-kid |
the header names a key this verifier does not hold |
alg-mismatch |
the header's alg is not that key's algorithm |
bad-signature |
the signature does not verify over the bytes received |
expired |
exp is in the past by more than the skew |
not-yet-valid |
nbf is in the future by more than the skew, or — under maxAgeSec — so is iat |
too-old |
the token granted itself a longer life (exp - iat) than maxAgeSec |
no-expiry |
maxAgeSec was set and the token omits exp or iat; or issuedWithinSec was set and it omits iat |
wrong-issuer |
issuer was set and iss is not one of the issuers given |
no-issuer |
issuer was set and the token has no iss |
wrong-audience |
audience was set and no aud value matches it |
no-audience |
audience was set and the token has no aud, or an empty array of them |
With issuedWithinSec, issuer and audience all unset, iss and aud are never read
and every verdict is what it was before those options existed.
maxAgeSec bounds how long a token is accepted: at most that many seconds of life,
starting no later than now. It needs both exp and iat to say so, and it checks
three things, each of which is a way around the other two:
iatis not in the future —not-yet-valid. Bounding onlyexp - iatwould let a sixty-second token dated thirty years ahead through today and thirty years from now, because a forward-dated token carries its acceptance window with it.expdoes not precedeiat—invalid-claims.exp - iatis at mostmaxAgeSec—too-old.
A token missing either claim is refused rather than exempted (no-expiry): a caller sets
the option because a token of unbounded life is not acceptable here, and a policy that
silently skips itself is not one.
exp, nbf and iat must be finite numbers when they are present at all. exp: "1800000000" is invalid-claims, not "no expiry given" — a separate reason from
malformed because a signature-valid token with a broken claim is your issuer's bug, and
three bytes of garbage is somebody else's traffic. The clock is read before the policy, so
a token that is simply dead reports expired rather than no-expiry.
The signature is checked over the exact header.payload bytes that arrived. The payload is
never re-serialized first, so a token stays valid regardless of how a JSON library would
have ordered its keys — and a re-serialized payload with the original signature is
correctly rejected.
There is no size limit here. A 1 MiB payload signs and verifies. If you read tokens off a network, cap the length before you call — that is a decision to make on purpose, not one to inherit.
Issuer, audience and issue date
These three run after every check above, in this order: issuedWithinSec, issuer,
audience. Setting them never changes why a token is refused — it only refuses some tokens
that would otherwise have been accepted.
issuer: string | string[]—issmust be one of these. Missing isno-issuer, a different string iswrong-issuer, a non-string isinvalid-claims.audience: string | string[] | { prefix, rest }—audmay be a string or an array of strings (RFC 7519 §4.1.3); one value matching is enough, and the successful verdict carries it asaudience. Missing or an empty array isno-audience, no match iswrong-audience, andaudthat is neither a string nor an array of strings isinvalid-claims— one non-string member is enough.issuedWithinSec: number—iatno more than that many seconds ago, plus the skew:too-oldbeyond it,no-expirywithoutiat. A token with a lifetime of an hour can still be required to have been issued within the last five minutes, whichmaxAgeSeccannot say. Aniatfurther ahead than the skew isnot-yet-valid, for the reasonmaxAgeSecrefuses one: a forward-dated token carries its acceptance window with it.
An empty list, an empty string, or a negative issuedWithinSec is a configuration error and
throws, as bad keys do.
Per-request audiences
Some audiences carry a value that differs per request — a callback URL that ends in a
one-time nonce, say. They cannot be compared exactly, so audience also takes a prefix and
a pattern for what follows it:
const result = verifyJws(token, keys, {
audience: { prefix: 'https://issuer.example/register/', rest: /^[0-9a-f]{32}$/ },
});
if (result.ok)
{
const nonce = result.audience!.slice('https://issuer.example/register/'.length);
}
restmust be anchored: its source starts with^and ends with an unescaped$, and it may carry no flag butuorv. Anything else throws on every call. An unanchored pattern would let…/register/<nonce>/../reportthrough because it merely contains a match.restis also evaluated as^(?:rest)$, so a top-level alternation (^a|b$) cannot slip out of its anchors.- An empty remainder is
wrong-audiencewhateverrestaccepts. - The prefix is compared as a string. End it at a boundary —
/,:— or letrestdraw one:https://issuer.example/ais also a prefix ofhttps://issuer.example/ab…. restis yours, and it runs on every request against attacker-chosen text. Keep it a character class and a length; a pattern that backtracks catastrophically is a denial of service this package cannot see.
If you keep a replay store, spend the nonce only after every other check has passed —
signature, time claims, issuer, audience, and your own. A request refused for any other
reason must not burn a nonce the legitimate caller still holds, and verifyJws() returning
ok is the earliest point at which spending it is safe.
Verifying tokens you did not sign
An OIDC issuer publishes its public keys as a JWK Set and signs with RS256. This package verifies those tokens with the same verifier and the same verdicts; what it does not do is fetch the set. There is no HTTP here — fetching, caching and refreshing the set is the caller's.
import { parseJwks, verifyJws } from '@spfn/signing/verify';
const { keys, skipped } = parseJwks(await response.text()); // once per fetch, not per token
const result = verifyJws(token, keys, {
issuer: 'https://issuer.example',
audience: 'https://api.example',
issuedWithinSec: 300,
});
Never accept a token you did not sign without issuer and audience. Every client of
that issuer holds a token its keys verify; the audience is what says the token was meant
for you.
verifyJws() also accepts the JWK Set object itself, and parses it on every call — every
key imported again for every token. Parse it once per fetch with parseJwks() and reuse
.keys. Pass .keys, not what parseJwks() returns: that object is not a key source, and
verifyJws() throws on it.
RSA keys
Verify only. This package never signs RS256: an RSA signature is four times the size of an Ed25519 one for no gain on tokens you issue, and a signer that can produce three algorithms is three algorithms to rotate. RSA is here because issuers you do not control use it.
| Rule | Value | Why |
|---|---|---|
| Modulus | 2048–8192 bits | the floor is RFC 7518 §3.3; the ceiling bounds what one request costs |
| Exponent | 65537 (AQAB) only |
every mainstream issuer uses it, and small exponents have a history of forgeries |
| Padding | PKCS#1 v1.5 only | that is RS256; PSS is PS256, another algorithm, and is not accepted |
| Signature length | exactly the modulus length | a signature with its leading zero bytes stripped is the same number — accepting it would give one token two encodings. Nothing is left-padded |
An RSA key reaches the verifier through a JWK Set or through
rsaPublicKeyEntry(kid, keyObjectOrJwk). The kid:key string format does not take
RSA keys and still refuses them: it is the format of keys this package hands out.
An RSA key decides its algorithm like any other. It verifies RS256 and nothing else, and
an entry that labels an RSA key EdDSA or ES256 throws. Every hand-built entry is held to
the same rule: its alg must be the algorithm its key verifies, so an rsa-pss, P-384 or
secp256k1 key throws whatever it is labelled, and so does an entry with no string kid or
no KeyObject.
JWK Sets
parseJwks(set) takes the set as JSON text or as a parsed object and returns
{ keys, skipped }. Pass the text when you have it: duplicate members are refused as they
are in a token, and a parsed object has already lost them.
| Input | Result |
|---|---|
kty RSA, OKP with Ed25519, EC with P-256 |
read |
an unknown kty (oct, anything new), an unknown curve (X25519, Ed448, P-384) |
skipped |
use other than sig, or key_ops without verify |
skipped |
an alg this package does not verify (RS384, PS256, …) |
skipped |
an alg the key contradicts (kty: RSA with alg: ES256) |
throws |
a read key without a kid, or two with the same one |
throws |
broken material: non-canonical base64url, n with a leading zero byte, e other than AQAB, a weak RSA key, a point off the curve |
throws |
private members (d, p, q, …) on a key of a kind this package reads |
throws |
a key given only as a certificate (x5c, x5t) |
throws — n and e are what is read |
| no usable key left after skipping | throws |
| more than 64 keys, or JSON text over 64 KiB | throws |
Unknown kinds are skipped because RFC 7517 §5 asks for exactly that, and because issuers
add new kinds of key to their sets: an encryption key appearing next to yours must not stop
your verification. A skipped key verifies nothing — its tokens are unknown-kid — so
skipping costs no safety.
A broken key of a kind this package reads refuses the whole set, because it is not someone
else's kind of key: it is a broken or substituted one. Dropping it quietly would turn it
into an unknown-kid nobody can trace. Treat the throw as "this fetch is bad" and keep the
set you had.
A JWK Set's kid is any 1–128 printable ASCII characters without whitespace — wider than
the string format's, since it never passes through the kid:key separator.
Purposes
An application usually issues more than one kind of token. definePurposes() declares them,
and keeps each one's tokens out of every other one's verifier: each purpose owns its keys
(every kid starts with its kidPrefix and a dash) and its audience (every token it signs
carries it, and its verifier requires it).
import { definePurposes } from '@spfn/signing';
const purposes = definePurposes({
api: { kidPrefix: 'api', audience: 'https://issuer.example/api', maxTtlSec: 300 },
webhook: {
kidPrefix: 'whk',
audience: 'https://issuer.example/webhook',
scoped: true, // aud = `${audience}:${scope}`
maxTtlSec: 60,
typ: 'webhook+jwt',
},
});
// kid:alg:provider:ref, separated by commas or newlines
const loaded = await purposes.load({
api: 'api-2026-09:EdDSA:gcp-kms:projects/p/locations/l/keyRings/r/cryptoKeys/api/cryptoKeyVersions/3',
webhook: process.env.WEBHOOK_SIGNING_KEYS!, // whk-2026-09:EdDSA:local:<base64url PKCS#8>
});
const token = await loaded.signer('api').sign({ sub: userId }); // aud, iat, exp written
const check = loaded.verifier('webhook', { scope: 'tenant-7' }); // its keys, its aud, its TTL
load()throws when one public key appears twice — in two purposes, or twice in one — when a kid lacks its purpose's prefix, when a purpose holds more thanmaxKeys(default 2: one rotation in flight), and when a KMS key is not the algorithm its line names. Two purposes sharing akidPrefixor an audience are refused bydefinePurposes()itself.- A purpose's signer writes
aud,iatandexp, refuses claims that carry any of them, and refuses attlSecabovemaxTtlSec. verifier()pins the purpose's keys, its audience, andmaxAgeSec: maxTtlSec.purposeVerifier(spec, keys)in@spfn/signing/verifybuilds the same thing where only the verifier runs.exclude: [kid]onpublicKeys(),jwks()andverifier()takes a retired key out. Which keys are retired, and which kid signs (signer(name, { kid })), is the caller's to remember — there is no storage here.- No error message quotes a key configuration line: a
localline carries a private key.
Rotation
A KeyRing holds up to maxKeys public keys (default 2) and knows which one signs. Both
keys and publicKeys() hand out a copy — a real one, not a ReadonlyMap type a cast
would walk through — so the ordering below is the only way the set changes. The order is the whole design:
add → switch → wait(maxTokenTtl) → remove
Skip add and verifiers reject tokens from a key they have not been told about. Skip
wait and you strand every token the old key signed that has not expired yet. Both are
outages, so KeyRing refuses to remove the current key and rotate() will not run the
steps out of order.
import { generateLocalKeyPair, KeyRing, rotate } from '@spfn/signing';
const ring = KeyRing.fromPublicKeysString(process.env.SPFN_BRIDGE_PUBLIC_KEYS!);
let plan = {
incoming: { kid: 'bridge-2026-09', alg: 'EdDSA', public: newPublicKey },
outgoing: 'bridge-2026-08',
maxTokenTtlSec: 300,
};
// Once per deployment step; persist `plan` in between.
({ plan } = rotate(ring, plan));
// Hand this to every verifier.
process.env.SPFN_BRIDGE_PUBLIC_KEYS = ring.toPublicKeysString();
shouldRotate(createdAt, rotationDays) answers whether a key is old enough to replace, and
warns seven days out. @spfn/auth keeps its own shouldRotateKey, which answers the same
question: six lines of date arithmetic are not worth a runtime dependency between two
published packages, and the ordering constraint that would come with one.
Test vectors
contracts/signing/vectors.json holds forty tokens — valid, expired and corrupted for each
algorithm, EdDSA first, then one per rule, RS256 and the issuer and audience options — with
the public keys that verify them and the verdict each must produce, and a block of JWK Sets
with what parseJwks() makes of each. It is
generated from fixed key material by src/vectors.test.ts, which also re-runs the
generation on every test run and compares. Regenerate with:
UPDATE_SIGNING_VECTORS=1 pnpm --filter @spfn/signing test
ECDSA picks a fresh nonce per signature, so an ES256 token is never byte-stable; the comparison covers the keys, headers and payloads, and the committed tokens are checked by verifying them. Ed25519 and RSA PKCS#1 v1.5 signatures are deterministic, so every other vector is compared byte for byte.
Migrating @spfn/auth's client tokens
generateClientToken() in @spfn/auth signs an ES256 JWT with jsonwebtoken from a
base64 DER private key. It maps onto this package directly — a LocalSigner with an
explicit alg: 'ES256', the DER as its key material, and timeClaims({ ttlSec }) for the
exp it currently spells expiresIn — with two differences to work through first:
jsonwebtokenwritestyp: 'JWT'and setsissby default. Both are expressible here (sign(payload, { typ: 'JWT' })and anissclaim), but they have to be written down rather than inherited.- Client tokens today carry no
kid, and this package requires one. Existing clients need a rollout that accepts both shapes before the old one is dropped.
That migration is not part of this package's first release, and nothing in @spfn/auth
changed for it.
API
@spfn/signing
createSigner(config)—{ provider: 'local' | 'gcp-kms' | 'aws-kms', … }→Promise<Signer>. This is the only way to reach the KMS providers: their modules are loaded on demand, so alocal-only process never parses them, let alone imports an SDK.LocalSigner— the local provider directly, when a promise buys you nothinggenerateLocalKeyPair(alg?)— a fresh key pair; Ed25519 unless you say otherwiseKeyRing,rotate(),rotationStage(),shouldRotate()timeClaims(),withTimeClaims(),signCompact(),CompactSignerderSignatureToJose()— DERSEQUENCE { r, s }→ JOSEr || sdefinePurposes(specs)→{ names, spec(), parseKeyConfig(), load() };load(config)→{ signer(), publicKeys(), jwks(), verifier() }- everything below
@spfn/signing/verify
verifyJws(token, keys, options?)→{ ok: true, header, payload, audience? } | { ok: false, reason }parseJwks(set)→{ keys, skipped };rsaPublicKeyEntry(kid, keyObjectOrJwk)purposeVerifier(spec, keys, { scope?, clockSkewSec? })→(token, now?) => VerifyResultparseCompact(token)→ the parts and the signed bytes, ornullparsePublicKeys(),parsePublicKeyEntry(),formatPublicKeys(),formatPublicKeyEntry()publicKeyToJwk(),toJwks(),rawPublicKey(),algorithmOf()encodeBase64Url(),decodeBase64Url()— strict, unpadded
Requirements
Node 22.23.4+ (or 24.22.1+, 26.12.1+), the range every SPFN package declares: earlier releases lack the fix for CVE-2026-111497, and Node 20 reached end of life without one.
The package's own constraint is lower: require(esm). This package ships ESM only, and
scripts/check-exports-require.mjs requires every subpath to declare a require condition
so a CommonJS consumer — spfn dev loads server.config.ts through tsx's CJS register —
can resolve it. Both conditions point at the same ESM file, and require()-ing ESM is what
Node supports from 20.19 and 22.12. Nothing in the cryptography needs a recent Node: Ed25519 and
dsaEncoding: 'ieee-p1363', which is how ES256 signatures come out in JOSE form without a
WebCrypto detour, have both been there since Node 12.
Changelog
0.1.0-alpha.3
Changed: engines.node is ^22.23.4 || ^24.22.1 || >=26.12.1. Node 20 is no longer supported: it
reached end of life without the fix for CVE-2026-111497.
0.1.0-alpha.2
Added: RS256 verification, JWK Set input (parseJwks(), rsaPublicKeyEntry()), the
issuer, audience and issuedWithinSec options, and purposes.
Changed behaviour. Default-option verdicts on well-formed key sources are unchanged. Mislabelled hand-built entries now throw at configuration:
- An entry whose
algis not the algorithm its key verifies throws whenverifyJws()reads it. In 0.1.0-alpha.1,{ kid, alg: 'EdDSA', public: <RSA key> }in aMapor an array verified an RSA PKCS#1 v1.5 signature under anEdDSAheader asok: true; it now throws. So do entries holding a key this package does not verify (rsa-pss, P-384, secp256k1), whatever their label. - An entry without a string
kidor aKeyObjectpublicthrows — for instance the result ofparseJwks()passed where its.keysbelongs.
Types.
verifyJws()has two overloads: aPublicKeySourcegivesVerifyResult(EdDSA or ES256 headers), any otherVerifyKeySourcegivesVerifyResult<VerifyAlgorithm>(RS256 included).ReturnType<typeof verifyJws>takes the last overload, so it is now the wide type; nameVerifyResultdirectly if you want the narrow one. The overloads stay in this order because TypeScript picks the first that matches: with the wide one first, a call with aPublicKeySourcewould resolve to the wide result too.VerifyFailureReasongainedwrong-audience,no-audience,wrong-issuerandno-issuer. They are returned only when the matching option is set, but an exhaustiveswitchover the type must handle them.
License
MIT