Verify a certificate offline
A certificate is only worth something if you can check it without asking us whether it is good. This page is the procedure, with no step that requires trusting this site.
What a certificate is
Section titled “What a certificate is”A compact JWS: three base64url segments separated by dots, signed with ES256 (ECDSA over P-256 with SHA-256).
eyJhbGciOiJFUzI1NiIsImtpZCI6InBzbi1wcm9kLTIwMjYtMSJ9.eyJjaWQiOiJjZXJ0XzAxai4uLiJ9.MEUCIQ…└──────────── header ────────────┘ └───────── payload ─────────┘ └─ signature ─┘The PDF is a rendering; the JWS is the credential. The transparency log hashes the compact JWS, never the PDF, so re-rendering a certificate cannot change whether it verifies.
The three checks, in order
Section titled “The three checks, in order”Verification is three independent questions, and the order matters because a later answer cannot rescue an earlier failure:
- Is the signature good? Resolve the header’s
kidin the published key set and verify the signature overheader.payload. - Is the hash in the transparency log? Compute
SHA-256of the whole compact JWS string and look for it in a log segment. A validly signed certificate that is not logged is unverified — that is the property the log exists for: it makes rogue issuance detectable. - What does the status record say? Valid, expired, revoked, or superseded. Expiry is not revocation, and revocation is prospective.
Step 1 — get the key set
Section titled “Step 1 — get the key set”curl -s https://purposesource.org/jwks.json -o jwks.json# The same document is served from the edge, and committed to the spec repository:curl -s https://api.purposesource.org/jwks.json | diff - jwks.json && echo "identical"The key set carries every key ever used, current and retired, each with a status and a
validity window — so a certificate signed under a retired key still verifies. Key ids follow
psn-{env}-{yyyy}-{n}; the production key is psn-prod-2026-1.
A third copy is committed, with signed commits, to the public specification repository. Compare the three: substituting a key on the website alone cannot forge trust.
Step 2 — verify the signature
Section titled “Step 2 — verify the signature”In a browser, with WebCrypto
Section titled “In a browser, with WebCrypto”This is what the verification page does, in your own browser, with no server in the trust path:
const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
async function verify(compactJws, jwks) { const [h, p, s] = compactJws.split('.'); const header = JSON.parse(new TextDecoder().decode(b64u(h))); if (header.alg !== 'ES256') throw new Error('unexpected alg: ' + header.alg);
const jwk = jwks.keys.find((k) => k.kid === header.kid); if (!jwk) throw new Error('kid not in the published key set: ' + header.kid);
const key = await crypto.subtle.importKey( 'jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'], );
// JWS ES256 signatures are raw r||s (64 bytes), which is what WebCrypto expects. return crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, b64u(s), new TextEncoder().encode(`${h}.${p}`), );}On the command line, with OpenSSL
Section titled “On the command line, with OpenSSL”OpenSSL wants a DER-encoded signature and a PEM public key, so two conversions are needed. The
JWK’s x and y are the raw P-256 coordinates:
JWS=$(cat certificate.jws) # the compact JWS, one line, no whitespaceH=${JWS%%.*}; REST=${JWS#*.}; P=${REST%%.*}; S=${REST#*.}
b64u() { python3 -c 'import base64,sys; d=sys.stdin.buffer.read().strip(); sys.stdout.buffer.write(base64.urlsafe_b64decode(d + b"=" * (-len(d) % 4)))'; }
# 1. the signing input, exactly as signedprintf '%s.%s' "$H" "$P" > signing-input.bin
# 2. r||s -> DER (openssl expects DER for ecdsa-with-SHA256)printf '%s' "$S" | b64u > sig-raw.bin # 64 bytes: r (32) || s (32)python3 - <<'PY'from asn1crypto.core import Sequence, Integerraw = open('sig-raw.bin','rb').read()assert len(raw) == 64, len(raw)r = int.from_bytes(raw[:32],'big'); s = int.from_bytes(raw[32:],'big')class Sig(Sequence): _fields = [('r', Integer), ('s', Integer)]open('sig-der.bin','wb').write(Sig({'r': r, 's': s}).dump())PY
# 3. the JWK for this kid -> PEM (kid comes from the JWS header)KID=$(printf '%s' "$H" | b64u | python3 -c 'import json,sys; print(json.load(sys.stdin)["kid"])')python3 - "$KID" <<'PY'import base64, json, sysfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives import serializationdef d(v): return int.from_bytes(base64.urlsafe_b64decode(v + '=' * (-len(v) % 4)), 'big')jwk = next(k for k in json.load(open('jwks.json'))['keys'] if k['kid'] == sys.argv[1])pub = ec.EllipticCurvePublicNumbers(d(jwk['x']), d(jwk['y']), ec.SECP256R1()).public_key()open('key.pem','wb').write(pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo))PY
# 4. verifyopenssl dgst -sha256 -verify key.pem -signature sig-der.bin signing-input.bin# -> Verified OKStep 3 — check transparency-log inclusion
Section titled “Step 3 — check transparency-log inclusion”Hash the whole compact JWS string, then look for that hash in a log segment:
printf '%s' "$(cat certificate.jws)" | sha256sum# 9f2c… -
curl -s https://api.purposesource.org/ct/latest.json | grep -i 9f2c# numbered segments, for a certificate older than the current segment:curl -s https://api.purposesource.org/ct/11.json | grep -i 9f2cThe same segment files are committed to the ct/ tree of the public website repository and
appended by pull request, with a continuous-integration guard that rejects any diff editing or
deleting an existing entry. Monthly checkpoints — a signed git tag plus a checkpoint token
signed with the production key — commit the log head, so an entire log cannot be replaced
retroactively without the checkpoints disagreeing.
A log entry contains a hash, a type code, and a timestamp. No names, no email addresses, nothing personal: the log is safe to mirror forever, and mirroring it is the strongest check available to an outsider.
Step 4 — read the status record
Section titled “Step 4 — read the status record”curl -s https://api.purposesource.org/v1/verify/cert_01j… | jq '{status, typ, variant, period, ct, revocation}'status is one of valid, expired, revoked, superseded. ct is null for a
signed-but-unlogged certificate, and a client that sees null must render it as
unverified.
The verdict matrix
Section titled “The verdict matrix”The verification page’s decision is a pure function, published here in full and covered by a fixture per row. Nothing in it is a judgement call:
| Condition | Verdict | Asserts anything? |
|---|---|---|
kid matches psn-sandbox-* | TEST CERTIFICATE — not a production credential, deliberately absent from the log | No |
| No record under this identifier | NO CERTIFICATE WITH THIS ID | No |
kid not in the published key set | INVALID — unknown signing key | Yes |
| Key set unreachable | CANNOT VALIDATE THE SIGNATURE RIGHT NOW | No |
| Any other fetch failure | CANNOT VALIDATE RIGHT NOW | No |
| Signature does not verify | INVALID — signature does not verify | Yes |
| Signature not yet checked | SIGNATURE NOT CHECKED | No |
| Signature good, hash absent from the log | UNVERIFIED — not in the transparency log | Yes |
| Signature good, log unreadable | TRANSPARENCY-LOG INCLUSION UNKNOWN | No |
Signature good, logged, status: valid | VALID | Yes |
Signature good, logged, status: expired | WAS VALID for the period shown | Yes |
Signature good, logged, status: revoked | REVOKED — prospective; the historical window stood | Yes |
Signature good, logged, status: superseded | SUPERSEDED — follow the successor link | Yes |
| Signature good, logged, unrecognised status | STATUS NOT PUBLISHED | No |
Three properties the order protects:
- A sandbox certificate never renders as a production credential, whatever its signature says. Sandbox keys are a disjoint set at a separate path and sandbox-signed tokens never enter the log.
- A certificate absent from the log renders unverified even with a perfect signature. The log is not decoration.
- A failure to fetch asserts nothing, in either direction. “We could not check” is a distinct answer from “invalid”, and conflating them would make the page useless in exactly the situations where it matters.
Reading the payload
Section titled “Reading the payload”| Claim | Meaning |
|---|---|
cid | Certificate identifier — the value in the verification URL and the QR code |
typ | supporter · admin · contributor · license-status |
variant | For example entitlement, waiver, under-threshold |
sub | The subject as it elected to be named. Never an email address |
scope | { kind, repos } — pass covers every registered repository |
band | Present on organisation certificates; absent where there is no price |
period | validFrom / validUntil — the window the certificate attests |
kid | The signing key id, which decides which published key verifies it |
ct | { seq, segment } — the log position, or null |
A certificate never carries more subject data than it displays, and never an amount on an unfunded type — a waiver certificate has no fee, so it has no fee field.
If something does not check out
Section titled “If something does not check out”- Signature fails, or the key id is unknown: treat the artifact as invalid and report it to
security@purposesource.org. A forged credential is a security issue, not a support question. - Signature verifies but the hash is not in the log: this is the interesting case, and the one the log exists to surface. Report it the same way; it means either a bug in our issuance order or an issuance that should not have happened.
- Everything checks out but the claim on the artifact overstates it: that is a claims problem, not a cryptography problem — the abuse route, and the permitted wording is in the OSPO and legal pack.
Related
Section titled “Related”- The verification page — the same three checks, in your browser
- Keys and the transparency log — key custody, the ceremony, rotation
- Public API reference — the endpoints used above, with cache and rate limits
- Security — how to report what you find