Recipe: Offline JWS verification in a React app
Verify a Verdacert certificate entirely client-side. No API key (the JWKS is public), no round-trip to Verdacert on every check, just jose + the cached key set. ~80 lines total including the UI.
When this is the right pattern
Three common scenarios.
- You receive a JWS from someone else— a partner agent, a customer pasting a string, another service — and want to prove it's authentic before acting on it.
- You display Verdacert artifacts at high volume and don't want every page view to round-trip our
/verifyendpoint. - Privacy-sensitive contexts (immigration law, medical) where avoiding even an HTTPS call to a third party matters.
/api/v1/verify/<certificateId> — see /docs/certificates#revocation.Install
jose is the same library we use server-side. ~40 KB minified, tree-shakes well.
pnpm add jose
# or: npm i joseThe verifier
Pure function. Returns the decoded payload on success or throws on any failure (bad signature, wrong alg, unknown key id, parse error).
// lib/verdacert-verify.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
// One module-level JWKS handle. Fetches once, caches for the cooldown
// window, transparently refreshes on a kid miss. Reuse across calls.
const jwks = createRemoteJWKSet(
new URL("https://verdacert.com/.well-known/jwks.json"),
{ cooldownDuration: 60 * 60 * 1000 }, // refresh at most hourly
);
export interface VerdacertReceipt {
certificateId: string;
orderId: string;
documentSha256: string;
translatorName: string;
issuedAt: string;
isSandbox: boolean;
}
export async function verifyVerdacertJws(jws: string): Promise<VerdacertReceipt> {
const { payload } = await jwtVerify(jws, jwks, { algorithms: ["EdDSA"] });
// Tightening the type — jose returns JWTPayload (Record<string, unknown>).
return payload as unknown as VerdacertReceipt;
}The component
A small client component: paste a JWS, get pass/fail + the decoded fields.
// components/verdacert-checker.tsx
"use client";
import { useState } from "react";
import { verifyVerdacertJws, type VerdacertReceipt } from "@/lib/verdacert-verify";
export function VerdacertChecker() {
const [input, setInput] = useState("");
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "bad">("idle");
const [receipt, setReceipt] = useState<VerdacertReceipt | null>(null);
const [error, setError] = useState<string | null>(null);
async function check() {
setStatus("checking");
setError(null);
setReceipt(null);
try {
const r = await verifyVerdacertJws(input.trim());
setReceipt(r);
setStatus("ok");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus("bad");
}
}
return (
<div style={{ maxWidth: 560 }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Paste a Verdacert JWS receipt…"
rows={4}
style={{ width: "100%", fontFamily: "monospace", fontSize: 12 }}
/>
<button onClick={check} disabled={!input || status === "checking"}>
Verify
</button>
{status === "ok" && receipt && (
<div style={{ marginTop: 12, padding: 12, border: "2px solid green" }}>
<strong>Valid signature ✓</strong>
<dl>
<dt>Certificate id</dt><dd>{receipt.certificateId}</dd>
<dt>Issued</dt><dd>{new Date(receipt.issuedAt).toLocaleString()}</dd>
<dt>Translator</dt><dd>{receipt.translatorName}</dd>
<dt>Document SHA-256</dt><dd><code>{receipt.documentSha256}</code></dd>
<dt>Environment</dt><dd>{receipt.isSandbox ? "Sandbox (not for production filings)" : "Production"}</dd>
</dl>
<p style={{ fontSize: 12, color: "#666" }}>
Note: this does not check revocation. For that, call{" "}
<code>/api/v1/verify/{receipt.certificateId}</code>.
</p>
</div>
)}
{status === "bad" && (
<div style={{ marginTop: 12, padding: 12, border: "2px solid red" }}>
<strong>Invalid ✗</strong>
<p>{error}</p>
</div>
)}
</div>
);
}Bind the receipt to a file (extra credit)
A valid JWS only proves we signed a certificate for a document with hash documentSha256. If you also have the PDF the receipt is for, hash it and compare:
Hashing a File in the browser
async function fileSha256Hex(file: File): Promise<string> {
const buf = await file.arrayBuffer();
const hash = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// In your component:
const sha = await fileSha256Hex(selectedFile);
if (sha === receipt.documentSha256) {
// The PDF the user holds IS the one we certified.
} else {
// Tampered with, or wrong file selected.
}Bundle-size note
jose ships an Ed25519 verifier in pure JS plus a WebCrypto path.
With tree-shaking you ship ~12 KB gzipped for the EdDSA-only verify path. If you're bundle-conscious, you can also import { jwtVerify } only and skip the rest of jose; bundlers like esbuild handle that cleanly.
