JWK and JWKS JWT Verification
ZinTrust includes JwtVerifier for the common case where your app needs to trust tokens issued by another platform.
This is useful when you accept identity tokens or access tokens from providers such as Apple, Google, Auth0, Azure AD, or your own external identity service. Instead of copying provider-specific verification code into each project, you can verify the token with a single reusable helper.
When to use JwtVerifier
Use JwtVerifier when:
- the JWT was signed by another system
- the public key comes from a JWK or JWKS endpoint
- you want issuer and audience checks in the same verification step
- you need the code to work in both Node.js and Cloudflare Workers
If you are signing and verifying your own application tokens with local secrets or keys, keep using JwtManager.
Verify with a JWKS URL
This is the most common path. ZinTrust fetches the JWKS document, finds the matching kid, imports the public key with WebCrypto, verifies the signature, and then validates the claims you asked for.
import { JwtVerifier } from '@zintrust/core';
const payload = await JwtVerifier.verifyWithJwks({
token: identityToken,
jwksUrl: 'https://appleid.apple.com/auth/keys',
issuer: 'https://appleid.apple.com',
audience: process.env.APPLE_CLIENT_ID,
cacheTtlSeconds: 3600,
});
console.log(payload.sub);What happens during verification
JwtVerifier.verifyWithJwks(...) checks:
- the token has a valid JWT structure
- the header algorithm matches
RS256 - the header includes a
kid - the JWKS endpoint returns a valid keys array
- the matching key can be imported as an RSA verification key
- the signature is valid
expandnbfclaims still allow the tokenissmatches your expected issuer when you provide oneaudmatches your expected audience when you provide one
Verify with a single JWK
If you already have the exact public key, you can skip the network call and verify directly.
import { JwtVerifier } from '@zintrust/core';
const payload = await JwtVerifier.verifyWithJwk({
token,
jwk: providerPublicKey,
issuer: 'https://issuer.example.com',
audience: 'my-api-client',
});This is a good fit when:
- your provider gives you a fixed public key
- you cache keys somewhere else in your application
- you want full control over how keys are loaded
Result mode vs throwing mode
ZinTrust gives you two styles.
Throwing mode:
const payload = await JwtVerifier.verifyWithJwks({
token,
jwksUrl: 'https://issuer.example.com/.well-known/jwks.json',
issuer: 'https://issuer.example.com',
audience: 'web-client',
});Result mode:
const result = await JwtVerifier.verifyWithJwksResult({
token,
jwksUrl: 'https://issuer.example.com/.well-known/jwks.json',
issuer: 'https://issuer.example.com',
audience: 'web-client',
});
if (!result.ok) {
console.log(result.reason);
console.log(result.message);
return;
}
console.log(result.payload.sub);Use result mode when you want to branch on the exact failure reason without catching exceptions.
Supported failure reasons
verifyWithJwkResult(...) and verifyWithJwksResult(...) return clear machine-readable reasons such as:
missing_kidkey_not_foundjwks_fetch_failedinvalid_jwksinvalid_signatureissuer_mismatchaudience_mismatchtoken_expiredtoken_not_yet_valid
When you use the throwing APIs, ZinTrust raises a normal security error and includes the reason in error.details.reason.
JWKS caching
JwtVerifier keeps JWKS documents in an in-memory module cache so repeated verification calls do not fetch the same key set every time.
await JwtVerifier.verifyWithJwks({
token,
jwksUrl: 'https://issuer.example.com/.well-known/jwks.json',
cacheKey: 'issuer-main-jwks',
cacheTtlSeconds: 1800,
});Use cacheKey when different parts of your app should share the same cached document even if they build the URL in different places.
If you need to force a refresh, clear the cache.
JwtVerifier.clearCache('issuer-main-jwks');
// Or clear every cached JWKS document
JwtVerifier.clearCache();Practical example: Apple Sign In
import { ErrorFactory, JwtVerifier } from '@zintrust/core';
export const verifyAppleIdentityToken = async (identityToken: string) => {
const payload = await JwtVerifier.verifyWithJwks({
token: identityToken,
jwksUrl: 'https://appleid.apple.com/auth/keys',
issuer: 'https://appleid.apple.com',
audience: process.env.APPLE_CLIENT_ID,
cacheKey: 'apple-sign-in-jwks',
cacheTtlSeconds: 3600,
});
if (typeof payload.sub !== 'string' || payload.sub.trim() === '') {
throw ErrorFactory.createUnauthorizedError('Apple token did not include a subject');
}
return {
provider: 'apple',
providerUserId: payload.sub,
email: typeof payload.email === 'string' ? payload.email : undefined,
};
};Practical example: middleware or controller flow
import { ErrorFactory, JwtVerifier } from '@zintrust/core';
export const loginWithExternalProvider = async (token: string) => {
const result = await JwtVerifier.verifyWithJwksResult({
token,
jwksUrl: 'https://issuer.example.com/.well-known/jwks.json',
issuer: 'https://issuer.example.com',
audience: 'mobile-app',
});
if (!result.ok) {
throw ErrorFactory.createUnauthorizedError(
`External token verification failed: ${result.reason}`
);
}
return result.payload;
};Notes and limits
- The current helper verifies
RS256tokens. - The current helper expects RSA JWKs that include
nande. - The JWKS cache is process-local memory, which is usually what you want for runtime verification.
- If your provider rotates keys, keep a reasonable TTL so refreshes happen automatically.
Choosing between JwtManager and JwtVerifier
Use JwtManager when ZinTrust is the issuer.
Use JwtVerifier when another platform is the issuer and your app only needs to trust the token.