How verification works
What /verify actually does when you drop a PDF — hash lookup, PAdES validation, eIDAS QES detection, the lot.
/verify is the page anyone — buyer, auditor, signer — can use to
confirm a PDF is authentic. This doc explains exactly what runs
under the hood so the verdict copy isn't a black box.
What the page actually checks
When you drop a PDF on /verify, the file is hashed and sent
through three phases. Each phase can short-circuit with a verdict;
the rest only run on a miss.
Phase 1 — Hash lookup (letssign.now)
We SHA-256 the bytes you uploaded and look the hash up against every signed document we've ever issued. A single byte different and the hash misses — that's the whole point.
- Hit ⇒ verdict
verified(emerald). We show the document name, the workspace it came from, every signer's email + signing time, and the trusted-timestamp authority that recorded the seal. - Miss ⇒ Phase 2.
Phase 2 — letssign.now certificate check
The PDF still might be one we issued — if someone modified it after
signing, the hash is dead but our certificate is still embedded. We
parse the PAdES Contents block, pull the signer cert from the CMS
SignedData bag, and look its serial up against signed_documents.
- Our cert serial, hash differs ⇒ verdict
tampered(rose). The certificate is one we sealed with, but the bytes have changed since. Do not trust the signature. - Not our cert ⇒ Phase 3.
Phase 3 — Real PAdES validation (Python sidecar)
The PDF is signed but not by us. The question becomes "is this a valid PAdES signature from someone trusted?" — a question pyHanko
- a freshly-cached EU trust pool answer.
For every embedded signature the sidecar:
- Verifies the signature math over the document's
/ByteRange. - Builds a certificate chain from the signer cert to a trust
anchor, using Mozilla NSS (commercial CAs) plus the
eIDAS LOTL member-state trust lists (currently DE + LU,
scalable via
TRUST_LIST_COUNTRIES), plus Switzerland's ZertES federal TL, plus the AATL curated snapshot. - Validates each cert in the chain at the signing time, not "now" — otherwise any intermediate that's since expired fails a valid historical signature.
- Runs eIDAS
QualificationAssessorto classify the signature as QES (Qualified — equivalent to a handwritten signature in every EU member state) or AdES (Advanced — trusted but not formally qualified). - Detects the PAdES baseline profile: B-B (basic), B-T (timestamped), B-LT (long-term validation data embedded), B-LTA (archival timestamps stacked).
Possible verdicts at the end of Phase 3:
verified_external_qualified(emerald + EU pill). eIDAS QES — legally equivalent to a handwritten signature in every EU member state.verified_external_advanced(sky blue). Trusted CA but not formally qualified under eIDAS.untrusted_signer(amber). Signature is cryptographically intact but the certificate doesn't chain to any trust list we cover. Could be self-signed, internal CA, or a region we don't yet support.tampered(rose). Sidecar found a real CMS but the math doesn't validate.not_signed(slate). PDF has no embedded signature dict.
Optional second opinion — EU eIDValidator
When the local verdict is INDETERMINATE or FAILED, the sidecar optionally calls the EU Commission's free eIDValidator (DSS-as-a-service) for a second opinion. EU DSS has every member-state TL freshly cached and the authoritative ETSI verdict machinery, so it can often classify cases we can't — especially Italian InfoCert, Estonian Mobile-ID, and other QES variants with vendor extensions.
Disabled by default (each call adds 2-5s and contacts an EU endpoint).
Enable per-environment with EIDVALIDATOR_ENABLED=1. A circuit breaker
pauses calls after 3 consecutive failures so an EU outage doesn't pin
the verify endpoint.
What we deliberately don't do
- No CRL/OCSP revocation fetches at verification time. The embedded validation data inside the PDF (B-LT/B-LTA) is what we rely on. Live revocation lookups would add 1-5s and depend on the CA being reachable — both bad properties for a real-time check.
- No XAdES, no CAdES, no ASiC containers. Only PAdES (PDF signatures). If you have an XML signature to validate, the EU eIDValidator handles it directly.
- No "we accept any CA you trust" mode. Trust scope is what's in eIDAS LOTL + Swiss ZertES + AATL + Mozilla NSS. Adding more requires a code change (intentional — drift in the trust scope leaks legal weight).
Trust scope, in detail
| Source | Coverage | Refresh |
|---|---|---|
| eIDAS LOTL → member-state TLs | EU QTSPs (currently DE + LU active; scale via env var) | LOTL refreshes ~every 6 hours; pre-warmed every 5 min by Vercel Cron |
| Swiss ZertES | Swisscom, SwissSign, QuoVadis CH, DigiCert Switzerland, Swiss Government PKI | Same warm-up cadence |
| Adobe Approved Trust List (AATL) | Adobe-trusted commercial CAs not in Mozilla NSS | Manual quarterly refresh (Adobe doesn't publish a clean feed) |
| Mozilla NSS / certifi | Commercial CAs that also issue TLS (DigiCert, Entrust, GlobalSign, IdenTrust, QuoVadis…) | Bundled with certifi; refreshed on dep bump |
Auditability
Every verification — including failures — emits a structured
verify_event log line on the server side. Ops can grep Vercel's log
stream to build verdict histograms over time:
# Verdict counts in the last 7 days
vercel logs --since 7d \
| jq -r 'select(.type=="verify_event") | .reason' \
| sort | uniq -cLogs are PII-free: SHA truncated to 12 chars, no emails, no PDF bytes.
Fields: reason, ok, sha256_prefix, size_kb, latency_ms,
signer_cn (when known), qualified (boolean), profile, source
(which phase decided).
