API documentation

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 /verify endpoint.
  • Privacy-sensitive contexts (immigration law, medical) where avoiding even an HTTPS call to a third party matters.
Don't use this for revocation checks
Offline verification proves the JWS hasn't been tampered with and was signed by Verdacert. It does NOT detect revocation (which can happen post-issuance). For revocation status, you must call /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 jose

The 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.

Where to next

Get instant quotePricing