We built a PDF signer that never uploads your files. Here's how.
A technical walkthrough of how Signegy signs PDFs entirely in the browser using pdf.js and pdf-lib, with honest notes on the hard parts.
Signegy is a PDF signer that runs entirely in the browser tab. You pick a PDF, draw or type a signature, drag it onto a page, and download a signed file. At no point does the document leave your device. This post walks through how that works, which libraries do the heavy lifting, and where we ran into friction. It’s aimed at anyone who wants to poke under the hood, whether you landed here from Hacker News, from r/webdev, or from our own sign PDF online page wondering if the “no upload” thing is really true.
The problem in one paragraph
The e-signature category has a quiet pattern: you hand your document to a third party to do something your own computer is perfectly capable of doing. That handoff has a cost. Nitro PDF disclosed a breach in 2020 that exposed user records and document metadata. More recently, PDF Pro and the service marketed as “HelpPDF” had S3 buckets found exposed with user-uploaded PDFs visible to anyone with the URL. These aren’t edge cases. They’re the predictable result of an architecture where every signed NDA, medical release, or purchase agreement gets mirrored onto someone else’s infrastructure. We wrote about the broader risk landscape on our is it safe to sign documents online page, and the privacy model we chose instead is on the private PDF signing page. This post is the engineering side of that decision.
The architectural claim
For a specific slice of PDF work (placing a signature, a stamp, or an image on one or more pages and saving the result), a modern browser has everything it needs. You can read a local file without a network request, parse and render a PDF, composite a signature, and write the result back to disk. No server step required. Honestly, it took us a while to fully internalize that. The muscle memory of “upload, then do the thing” is strong, and most of our instincts as web developers have been trained on architectures that assume a server in the middle.
Two libraries make this practical:
- pdf.js from Mozilla, which parses and rasterizes PDFs to canvas. It’s the same engine that renders PDFs natively in Firefox, so it has a huge surface area of battle-tested behavior.
- pdf-lib, which reads and writes PDF byte streams. This is the piece that actually modifies the document: adding an image XObject for the signature, mutating the page content stream, and emitting new bytes.
We use pdf.js for display only, and pdf-lib for every mutation. Keeping the two responsibilities separate turned out to matter, because their models of a PDF are different. pdf.js thinks of a page as “a thing to draw.” pdf-lib thinks of a page as “a tree of objects I can edit.” Trying to reuse one for the other’s job leads to pain, as we found out once while trying to get a clever shortcut working before giving up and going back to the two-library split.
How rendering works
When a user picks a PDF through the file input, the browser hands us a File object. We read it once into an ArrayBuffer and keep that buffer in memory for the lifetime of the session:
async function loadPdf(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
const doc = await pdfjsLib.getDocument({ data: bytes }).promise;
return { bytes, doc };
}
That’s the entire ingest path. No fetch, no FormData, no service worker forwarding, no analytics beacon quietly carrying a filename. The bytes sit in a typed array in the tab’s heap. When the tab closes, they’re gone.
For display, we walk through pages on demand. Each visible page gets its own canvas, sized to the device pixel ratio so the text stays crisp on retina displays. pdf.js’s page.render({ canvasContext, viewport }) returns a task we can cancel if the user scrolls past before it finishes. Scroll cancellation matters more than you’d think. Without it, a 200-page contract will happily queue 200 render tasks and melt the main thread (we shipped this bug to staging once, noticed the fan spinning, and fixed it that afternoon).
pdf.js doesn’t do everything. It won’t let you annotate, edit content streams, or write bytes back out. For display and text extraction it’s excellent. For writing, we need the other library.
How signing works
The signing step takes three inputs: the original PDF bytes, the placed signature (an image and a bounding box in PDF coordinates), and the page number. pdf-lib does the rest:
import { PDFDocument } from 'pdf-lib';
async function embedSignature(
pdfBytes: Uint8Array,
pngBytes: Uint8Array,
page: number,
box: { x: number; y: number; width: number; height: number },
) {
const doc = await PDFDocument.load(pdfBytes);
const png = await doc.embedPng(pngBytes);
doc.getPage(page).drawImage(png, box);
return doc.save();
}
A drawn signature is a <canvas> we rasterize to a PNG. A typed signature is the chosen font rendered into an offscreen canvas, then also exported as PNG. An uploaded signature image is converted to PNG if it isn’t already. Everything collapses into the same “embed a PNG, draw it on a page” path, which keeps the signing code small and easy to reason about.
We looked at alternatives before settling on pdf-lib. A pdfium WASM wrapper gives you more faithful rendering but is heavier to ship and harder to tree-shake. The Foxit JS SDK is commercial and adds a licensing axis we didn’t want. HummusJS has been unmaintained for years (check the repo, it’s a ghost town). For our scope, which is placing image content on existing pages, not rewriting form fields or re-laying out text, pdf-lib is the right tool. It’s pure JavaScript, it’s small enough that the initial signer payload stays reasonable, and its API surface roughly matches the shape of what we’re actually doing.
A wrinkle that caught us early: PDF coordinates have the origin in the bottom-left, while DOM coordinates have it in the top-left. Translating between “where the user dropped the signature on a rendered page” and “where pdf-lib should draw the image” requires knowing the page’s crop box, its rotation, and the current render scale. We have a small toPdfSpace helper that does this one conversion and nothing else, because every time we tried to inline the math it ended up wrong on rotated pages. Frankly, rotated pages are where everyone’s coordinate bugs live.
The UX parts that were hard
This is the section we wish we’d read before starting.
Signature canvas on mobile was the first surprise. A drawable canvas sounds trivial until a phone is involved. Touch events fire differently from mouse events, and on iOS the default behavior of a touch on a canvas is to scroll the page, not to draw on the canvas. The fix is touch-action: none on the canvas element, plus listening to pointer events rather than touch events, plus calling preventDefault on pointerdown to kill any residual gesture handling. Retina scaling is the next trap. If you size the canvas to its CSS pixels, the signature looks soft. The right move is to size the backing store to width * devicePixelRatio and scale the 2D context, so strokes land on real pixels. Not hard once you know it, but we shipped a soft-looking signature to staging twice before noticing.
Placing and moving the signature turned into a bigger project than we expected. Once there’s a signature on a page, the user wants to drag it, resize it, maybe rotate it. That means we’re running a small graphical editor on top of the rendered canvas: hit testing, handle rendering, pointer capture, keyboard nudges, undo. It isn’t as robust as Figma, and it isn’t trying to be. But “a placed object with eight resize handles” is more code than it looks, and we ended up writing a small state machine so that “dragging” and “resizing from the top-right handle” and “rotating” are explicit states rather than a tangle of booleans. Our first attempt, which did try to use booleans, lasted about a week before we gave up on it.
Rendering big PDFs without jank was another hurdle. A 300-page expert report shouldn’t freeze the tab. Our solution is page virtualization: the scroll container knows how tall each page will be before we render it (pdf.js gives us page sizes cheaply from the document catalog), so we can render placeholders for off-screen pages and only spin up real canvases for what’s visible, plus a one-page buffer above and below. When the user scrolls, we tear down canvases that have moved out of range. Standard list-virtualization work, just applied to pages instead of rows. It isn’t perfect: if someone flicks the scrollbar very fast, they’ll catch a few placeholder frames. We decided that was a fine trade-off.
Keeping the download an actual download was the last piece. We build a Blob from the bytes pdf-lib returns, create an object URL, and click a hidden anchor with a download attribute. There’s no S3 pre-signed URL involved and no “your file will be emailed to you” redirect step:
const blob = new Blob([outBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'signed.pdf';
a.click();
URL.revokeObjectURL(url);
This path is boring, which is the point. A server round-trip for the download would be a way to turn a no-upload architecture into an upload architecture by accident. Every time we touched this code we re-read it twice.
What we don’t do (and why)
We were tempted at several points to broaden the scope. We’ve mostly resisted. The things Signegy doesn’t currently support, and our thinking on each:
Form filling and text annotations is the most common feature request, and we’re working on it. It’s harder than signing, because we have to detect form fields, map them to an editable overlay, and then write the values back through pdf-lib’s form API without breaking existing field appearances. It’s on the roadmap, not in the product.
Dates as a separate tool is a three-line feature once typed signatures exist, so it’ll ship alongside form-fill rather than as its own page.
Initials as a distinct tool is the same reasoning. Initials are a short typed or drawn signature; the scaffolding already exists.
Multi-signer workflows is a firm “no” on the current architecture. Routing a document between two signers requires a server to hold state between them; that’s the definition of the feature. If we build it, it’ll be a separate product with its own privacy posture, not a bolt-on here.
Certificate-based digital signatures (PKI-backed, the kind a notary or certain EU workflows require) are a different beast. They involve a certificate, a hash of the document, and a cryptographic signing operation tied to an identity. Doing this in the browser is technically possible with WebCrypto, but integrating with trust roots like Adobe’s AATL or the EU Trusted List is a long project with its own compliance surface. We aren’t the right team for it yet, and we’d rather say that plainly than ship a half-finished version.
The short version: we’re trying to put a visible signature on a PDF without touching your data, and do that part well. We aren’t trying to be a DocuSign replacement for enterprise workflows.
The rest of the stack
We haven’t mentioned the outer layers, so briefly. The site is built with Astro, which suits a mostly-static marketing surface with a couple of interactive islands (the signer itself lives in a React island). Tailwind handles styling. It deploys to Cloudflare Pages via GitHub Actions, using wrangler to push each merge on main. None of that is exotic. If you’re curious about any of it, we’re happy to go deeper in a follow-up post.
Verify it yourself
You shouldn’t take our word for any of this. Here’s the honest test:
- Open the signer in a fresh tab.
- Open DevTools, go to the Network panel, and clear it.
- Drop a PDF into the page. Draw a signature. Place it. Download the result.
- Look at the Network panel. You’ll see the requests that loaded the site’s code and fonts. You won’t see a POST carrying your document.
If you want to be even more rigorous, turn off Wi-Fi after the page loads, then do the signing. The flow will complete. The download will work. That’s not something a server-based signer can offer, and it’s a more convincing proof than anything we could write in prose.
What’s next
Form-fill is the biggest near-term piece of work. Beyond that, we’re deliberately trying not to over-plan. The surprising thing about building a tool that treats “no data leaves the device” as a hard constraint is how often the right next feature isn’t obvious until people actually use the thing, and how often a feature we assumed was important turns out to be someone else’s product. We’ll keep writing posts like this one as we figure it out. If you have a use case that’s bumping into our current limits, we’d genuinely like to hear about it.
For the shorter, less technical version of the privacy argument, see the private PDF signing page. For the broader question of which online signing tools are actually safe, the is it safe to sign documents online guide goes through the trade-offs. And if you’re just here to sign something, the tool lives at sign PDF online.