Cryptographic Signing in the Browser: Building an e-Signature System with PKCS#11
Between late 2023 and early 2024 I worked on an electronic signature system for the Supreme Court of Justice of Panama. The premise was simple — let a judge or court clerk sign a PDF in their browser, with the same legal weight as ink on paper. The implementation was not.
The reason it gets hard is a thing browsers stopped doing a decade ago: talking directly to hardware.
Why browsers can't talk to smart cards anymore
Until 2015 or so, you could do this in Internet Explorer or Firefox via NPAPI plugins. A signed Java applet or a browser extension would speak PKCS#11 directly to a smart card driver, sign the document, and hand the signature back to the page. NPAPI is dead. WebUSB exists but doesn't cover the protocols that government smart cards actually use. The Web Crypto API can sign, but only with keys it generated — and you can't extract a hardware-backed legal-identity key into the browser by design.
So a modern browser, by itself, cannot produce a signature with a citizen's smart card. That's not a bug; it's the threat model. The fix is to add a small piece of trusted software between the browser and the card.
The shape of the solution
The pattern that works is a local signing agent — a small native application running on the user's machine that the browser talks to over HTTP on localhost. Roughly:
[Browser tab] ──fetch──▶ [localhost:NNNN signing agent]
│
│ PKCS#11
▼
[Smart card driver]
│
▼
[Hardware token]
The agent exposes a tiny REST API (POST /sign with the document hash and chosen certificate). The agent prompts the user for their PIN through a native dialog the browser can't fake, talks PKCS#11 to the card, returns a PKCS#7/CMS detached signature, and the browser uploads it back to the server.
A few details that matter:
- The browser never sees the PIN. Ever. The native dialog is the only place it's typed.
- The agent never sees the document body. The browser hashes the PDF, sends only the hash, and applies the returned signature back to the original bytes. The agent's threat surface is "signs arbitrary 32-byte digests for the user who already typed their PIN."
- Origin-locked. The agent's CORS policy whitelists exactly one origin (the court's domain). A malicious tab on
evil.examplecannot call the local agent.
The Java backend's job
The browser uploads a PKCS#7 signature blob plus the original PDF. The server's job is to:
- Verify the signature is well-formed — parse the CMS structure, check it's actually a detached signature over the expected hash.
- Validate the certificate chain to a trusted root. Panama has its own national PKI; the chain has to terminate there.
- Check revocation. CRL or OCSP — we used OCSP with a CRL fallback for when the responder was down.
- Embed the signature in the PDF as a PAdES-LTV (Long-Term Validation) signature. This means the timestamp, the certificate chain, and the OCSP response are all stuffed into the PDF itself, so the signature still validates years later when the responder is gone.
- Persist the signed PDF with an audit trail.
iText 7 and BouncyCastle did most of the heavy lifting on the Java side. Both are exactly the libraries you'd expect.
The thing I underestimated
The protocol design is the easy part. The user experience of "go install this little app on your machine before you can sign" is the hard part.
For the court, this was workable — IT-managed laptops, controlled rollout. For consumer-facing systems with the same problem (banks, tax agencies, notaries), I'd push harder for alternative signing flows alongside the smart-card path:
- Cloud HSM signatures with a separate strong authentication factor (mobile push, OTP). Legally equivalent in many jurisdictions if the HSM is government-certified.
- Mobile signature via SIM-based crypto, where the phone is the smart card.
Forcing a desktop install is a UX cliff. For a court system where compliance with a specific legal framework was the goal, fine. For anything else, the cliff is a bigger cost than people think.
What I'd put in the runbook
If you ever find yourself building one of these:
- Pin the JCA provider explicitly.
Security.addProvider(new BouncyCastleProvider())and then ask BC for everything signature-related. Don't let the JVM pick a default that changes between versions. - Test with revoked certificates. If your verification path doesn't reject a known-revoked cert from the test PKI, you have no verification.
- Treat the timestamp authority as a dependency. If your TSA is down, you can't produce LTV signatures. Have a fallback configured.
- Log signature events but not signature payloads. "User X signed document Y at time T" is an audit record. The full PKCS#7 blob in your logs is gigabytes of garbage and a small data leak risk.
The hardware crypto piece is well-trodden ground if you stay close to the standards. Most of the engineering judgment is at the seams between the browser, the local agent, the verification stack, and the user — and most of those seams aren't covered by any RFC.