Back to blog

June 26, 2026 · By Montte · 8 min read

SignatureKit × Licitei: signing in the browser, without per-signature fees

How Montte and Licitei moved A1 signing into the browser: zero marginal cost, the flow unbroken, the private key never leaving the device.

Licitei is one of the largest public-procurement (licitação) platforms in Brazil. To bid, a user has to sign: every proposal (proposta) and every qualification declaration (declaração de habilitação) needs a real digital signature with the bidder's A1 certificate.

That sounds solved — plug in an e-signature API and move on. Except the economics don't work, and the legal and procedural constraints leave no room to cut corners.

The cost of a signature

The major e-signature services bill against document or signature volume, so cost scales linearly with how much you sign. Clicksign sells a fixed monthly base — R$39/mo Start, R$59/mo Plus, R$85/mo Automação — bundled with a document allowance, and once you exceed it each additional document is billed individually, from R$2,40 up to R$6,90 depending on tier (Clicksign pricing). ZapSign is even more explicit: a digital-certificate signature is an add-on at R$0,50 per signature on top of the plan's document limit (ZapSign feature table).

For one document that's fine. But a licitação is never one document: a bidder signs dozens of documents at once, every time they enter a bid. Multiply per-document pricing by dozens of documents, by thousands of bidders, by every bid they file, and the line item stops being a feature cost — it becomes the product's cost. The per-document model penalizes exactly this usage; on Clicksign the per-doc overage rate actually rises with the higher-feature tiers at the entry allowance (R$3,00 on Start vs R$6,90 on Automação), only dropping if you pre-commit to a 200-document allowance (Clicksign pricing). Cost is indexed to document count.

The obvious escape — "let the user sign somewhere else and upload the result" — is worse. A licitação under the Nova Lei de Licitações (Lei 14.133/2021) is conducted preferentially in electronic form, with the public session opening at the date and time fixed in the edital (art. 17, §2º) (TCE-SP, art. 17). In the federal electronic pregão, bidders must submit the proposal and the habilitação documents together, exclusively through the system, and may only swap them out until the session opens; the dispute then runs on fixed timings — ten minutes in the open mode (Portal de Compras / Decreto do Pregão Eletrônico). Sending the bidder out of Licitei to a second tool mid-bid, against a deadline measured in minutes, loses bids.

What the signature actually has to be

The reason none of this can be faked with a drawn-on image: a proposta signed for a licitação needs to carry full legal weight. Brazilian law (Lei 14.063/2020) defines three tiers of electronic signature — simple, advanced, and qualified — and only the qualified tier uses an ICP-Brasil digital certificate, under §1 of art. 10 of MP 2.200-2/2001, the measure that created ICP-Brasil (Governo Digital). A document signed with an ICP-Brasil certificate carries the same legal validity as a paper document with a handwritten signature, per art. 10 of MP 2.200-2/2001 (ITI).

The A1 certificate is the software-based ICP-Brasil certificate: a file stored locally on the user's computer or phone, valid for one year — distinct from the A3, which lives on a smart card, token, or remote HSM and is valid for one to five years (ITI). Because the A1 is just a file the bidder already holds, it's the natural fit for signing in software — and in the browser.

Why "A1 signing" still costs so much

Here's the part that's easy to miss. Most services that do support A1 certificates still charge heavily for it, and the reason is architectural: they sign on a server. The user hands over the .pfx (or a managed key vault holds it) along with the document; the service signs server-side and returns the result.

The per-signature price isn't paying for the cryptography. A public-key signature — RSA or ECDSA — is computed over a fixed-size hash of the document, not the whole document, which is exactly what makes it fast (MDN, SubtleCrypto.sign()). What you're paying for is the server: infrastructure to run, a private key that leaves the user's machine, and the liability of holding that key.

That liability is the real cost. NIST is unambiguous that a signing private key should remain under the sole control of its owner and be protected against disclosure (NIST SP 800-57 Pt. 1 Rev. 5). Disclose it and "the integrity and non-repudiation qualities of all data signed by that key are suspect" — one exposure invalidates every signature the key ever produced (NIST SP 800-57 Pt. 1 Rev. 5). Doing this safely on a server is expensive on its own terms: AWS KMS meters asymmetric signing at $0.15 per 10,000 requests — explicitly excluded from the free tier — plus $1 per key per month, and hardware-backed custody via a CloudHSM custom key store adds HSM instances at $1.60 per hour, roughly $2,380 a month for the two HSMs AWS recommends for availability, before a single signature (AWS KMS pricing).

So Montte and Licitei asked the obvious question: what if the server isn't in the loop at all?

Sign in the browser

@signature-kit runs the entire signing flow in the browser. The PDF bytes, the A1 .p12/.pfx container, and its password stay in the page's local memory and never make a network request. The asymmetric operation is the same one the platform already exposes: the Web Crypto API gives a script direct access to cryptographic primitives via SubtleCrypto, and sign() produces a digital signature from a private key entirely client-side, with no server in the path (MDN, Web Crypto API). PAdES is computed in the page; the app gets back finished, signed bytes to download or store.

npm install @signature-kit/pdf @signature-kit/a1

Nothing leaves the device

The document, the .pfx, and the password live only in the browser tab. The password is wrapped as Redacted before it reaches a1SignaturesLayer, so it never lands in a log or an error message. No request is made — the private key never travels.

The flow

readBrowserFileBytes turns a File/Blob into a Uint8Array, all in memory. Failures are typed (react.FILE_READ_FAILED), never a thrown exception.

read.ts
import { readBrowserFileBytes } from "@signature-kit/pdf/browser"

const bytes = await Effect.runPromise(readBrowserFileBytes(file))

createBrowserPdfSignatureBuilderState loads the PDF's pages with pdf-lib and produces a validated template with the signature field positioned — explicitly, or auto-placed. Its template is what the signer consumes.

build.ts
import { createBrowserPdfSignatureBuilderState } from "@signature-kit/pdf/browser"

const state = await Effect.runPromise(
  createBrowserPdfSignatureBuilderState({
    id: "proposta",
    name: file.name,
    documentId: "proposta",
    documentName: file.name,
    pdf: bytes,
    role: { id: "signer-1", label: "Licitante", required: true },
    draft: { id: "sig", type: "signature", roleId: "signer-1", width: 180, height: 48 },
    placement: { pageIndex: 0, x: 360, y: 96, anchor: "center" },
  }),
)

signBrowserPdfBatch signs the whole set under one a1SignaturesLayer — the certificate is unlocked once and reused for every document. It signs strictly one-by-one, and a document that fails is captured as { ok: false, error } instead of aborting the run, so you always get one result per input, in order. onItemSettled fires as each finishes, so the UI can stream progress and hand each signed file straight back to the bid.

sign-batch.ts
import { signBrowserPdfBatch } from "@signature-kit/pdf/browser"
import { a1SignaturesLayer } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"

// `items` = one { id, input } per uploaded document, built with the step above.
const results = await Effect.runPromise(
  signBrowserPdfBatch(items, {
    onItemSettled: (result, index, total) => {
      if (result.ok) attachToBid(result.id, result.signedPdf)
      else markFailed(result.id, result.error.message)
    },
  }).pipe(
    // The certificate is provided ONCE, for the whole batch, and never leaves the tab.
    Effect.provide(a1SignaturesLayer({ pfx, password: Redacted.make(password) })),
  ),
)

That is the whole thing: dozens of documents, one certificate, one pass — and not a single byte sent to a server.

It still meets the Brazilian requirements

The output is PAdES. Need the rubrica em todas as páginas? Stamp it before signing with stampPdfRubric({ pages: "all" }), so a single signature's byte range covers every page. Need the ICP-Brasil policy embedded? Pass icpBrasil to the signing input. None of it changes where the signing happens — still the browser.

PAdES isn't a SignatureKit invention — it's PDF Advanced Electronic Signatures, the ETSI standard EN 319 142, which defines how an advanced electronic signature is embedded directly into the PDF so it travels with the document (PAdES). Computing it in the browser produces the same standard artifact a server would, signed with the same ICP-Brasil key — just without the key ever leaving the page.

What this changed for Licitei

  • Zero marginal cost per signature. The expensive part was the server, and there is no server. Signing dozens of documents per bid costs the same as signing one: nothing.
  • The certificate never leaves the device. The .pfx and its password stay in the tab, Redacted end to end. That is the strongest possible answer to "where did my private key go?" — it didn't go anywhere, and there is no server-side key to disclose.
  • It stays in the flow. The bidder never leaves Licitei. Upload, place, sign the batch, attach — inside the same screen, against the same deadline.
  • One bad document doesn't sink the batch. A failed signature is a typed result, not an exception. The other documents still sign; the failure surfaces with a code you can act on.

The cryptography was never the hard part — it's a hash signature measured in microseconds. Moving it off the server — and keeping it typed, batched, and in the browser — is what made qualified digital-certificate signing something Licitei could include, rather than meter.

Keep reading