Security
How wesign.now protects documents at rest, in transit, and over their lifetime — with the trade-offs called out.
This page lays out what we do, what we don't yet do, and where the trade-offs sit. If you're a buyer with a security questionnaire to fill in, every answer below is verbatim what our infrastructure does today.
Encryption at rest — envelope encryption
Uploaded and signed documents we write server-side are encrypted with a two-layer AES-256-GCM envelope on top of Vercel Blob's vendor-managed AES-256 encryption. The callout below says which files are not.
What's encrypted, what isn't:
The PDF you send through POST /v1/signing-requests (uploaded as file
or fetched from file_url), the PAdES-sealed signed PDF and the
DOCX→PDF conversion output are envelope-encrypted. Two kinds of file
keep Vercel's vendor encryption only:
- Browser-direct uploads through the in-app dashboard, where bytes go straight from your browser to Blob without our server seeing them. The only way to encrypt those would be a server-side roundtrip on every upload, doubling latency.
- PDFs we render from a template — the document of a
POST /v1/templates/{id}/instantiate, a stagedgenerate, the re-render at confirm, a dashboard quick-send or a form link — and the re-rendered copy of a document after a signer filled in a text field. The signed PDF that comes out of them is envelope-encrypted like every other.
How envelope encryption works
master key (in our Vercel env, never on Blob)
└── wraps a fresh 32-byte data key per document
└── encrypts the file bytes (AES-256-GCM)
stored alongside on the documents row:
encryption_alg = "aes-256-gcm/v1"
encryption_keymeta = {
wrapped_data_key, wrap_iv, wrap_tag,
file_iv, file_tag
}Both layers carry GCM auth tags — a single tampered byte fails the
tag check on the way back out, loud and immediate. The blob on Blob
reads as application/octet-stream (ciphertext); the metadata travels
in Postgres.
What the master key does + doesn't see
- The master key lives in our deployment environment variables. It never touches Blob, never appears in logs, never leaves the serverless runtime.
- We can decrypt documents when we serve them back to authorised callers — that's how the audit trail and signing flow work.
- Customer-managed keys (BYOK) are on the roadmap. When that ships, customers in regulated industries can hold the key themselves and we'll decrypt only when they hand it over per request. Today we hold the key.
Key loss
A practical consequence of holding any encryption key: if the master key is permanently lost, encrypted documents become permanently unrecoverable. The codebase rejects unknown algorithm versions explicitly so a future-version cipher can't silently mis-decrypt v1 blobs, which means recovery requires the matching key.
For your side: if you delete a workspace, withdraw payment access, or otherwise lose your account, you are responsible for retrieving any documents you need before doing so. We can't decrypt blobs for an account we can no longer authorise — and even if we could, we wouldn't (privacy + access-control).
Signature evidence
Independent of at-rest encryption, every signed document comes with:
- PAdES seal — embedded directly in the PDF, verifiable by any
compliant reader (Adobe Reader, Foxit, the EU's DSS validator), made with
our self-signed seal certificate. Seals made before the seal-certificate
change on 2026-10-22, 00:00 UTC (announced in the
changelog) carry
the previous seal certificate (RSA-2048) in the ISO 32000-1
adbe.pkcs7.detachedformat, the PAdES Basic profile of ETSI TS 102 778-2, not an ETSI EN 319 142-1 baseline signature. Seals made from the change carryCN=wesign.now Signing Authority(RSA-3072) asETSI.CAdES.detached, PAdES baseline B-T (ETSI EN 319 142-1): the signing certificate bound by ESS signing-certificate-v2, the signing time in the signature dictionary (/M) rather than in the CMS, and an RFC 3161 time-stamp over the signature value embedded in the signature. When no time-stamp authority answers at that moment the seal is made without the embedded time-stamp (B-B) rather than not at all. Every seal stays valid under the certificate that made it. SHA-256 fingerprints:0568a78abeb5b81e28c2ee4184a0d33b7b93f74647967c8e5b28391de626bcdc(before the change) and8a3d0158b768dd0d4244b4e20c6d1cbd99b1de4e651842a170ed42195bcb5d6d(from it); both, with their PEM, are listed at /seal-certificates.json. - RFC 3161 timestamp over the sealed PDF, from an independent
time-stamping authority — currently DigiCert, with freetsa.org as the
fallback; neither is an eIDAS-qualified one — proves when the
document was sealed, by a third-party clock. Taken for every seal when the
authority answers, before and after the change, and kept with our records
and on the audit certificate (
tsa_provider,tsa_signed_at). - Certificate of Completion (audit trail PDF) — issued once, when the
last signer's seal completes the document, and stored: the completion
email and
GET /v1/documents/{id}/audit-traildeliver the same bytes, whose SHA-256 isaudit_trail_sha256ondocument.completed. It prints the signed PDF's SHA-256, the seal certificate serial, the time stamp, each signature's assurance level (electronic signature, or with SMS 2FA), the creator (for a document created over the API, the workspace and the API key's name) and the events up to the last signature with masked IP addresses; user agents are recorded but not printed. It is a separate PDF, not itself sealed: keep it with its hash. See Download the audit-trail PDF.
Together, this stack is admissible as legal evidence under EU eIDAS and Swiss ZertES. Conservative claim, accurate framing.
Encryption in transit
- TLS 1.2+ on every endpoint (
api.wesign.now, signing pages, branded subdomains). - HSTS with
max-age=63072000; includeSubDomains; preloadon every response. The domain is on Chrome's HSTS preload list. - All webhook deliveries go out over HTTPS; receivers that don't serve TLS get a delivery error.
Webhook signing
Outbound events are signed with HMAC-SHA256 using a per-webhook secret:
X-WeSign-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<rawBody>")>.
It is sent alongside X-WeSign-Event-Id and X-WeSign-Event.
Per-document callbacks (registered via
callback_url on a v1 request) get their own per-document secret returned
once at creation; dashboard hooks show theirs once too. Receivers are
expected to verify the signature before trusting the body, reject a t
older than 300 s, and de-duplicate on the event id — see
Webhooks → Verifying a signature.
Access control on the API
The bearer key is the authorisation. Everything below narrows where that key works — defence in depth, layered on top of the key, never a replacement for keeping it secret.
Pinning a key to your egress IP addresses
Each API key carries a list of IPv4/IPv6 addresses and CIDR blocks
(allowed_ips, managed in Settings → API → Overview). When the list is non-empty,
a call from any other address is refused with 403 ip_not_allowed before the
key is allowed to do anything; the refusal names the address we saw so the
integrator can fix it in a minute.
An empty list means unrestricted, and that is deliberate: this control gates the API that live customers already depend on, so the default has to be "works exactly as before". Only a customer who deliberately adds an entry changes anything. (The embed-origin list below follows the same rule: empty there means any https origin may be named, and each session is still pinned to the one origin it names.)
Two honest limits, because this is easy to oversell:
- An IP allowlist limits where a stolen key can be used from. It does not make a leaked key safe. Anything on an allowed address — other code behind the same NAT, or whoever compromised it — can still use the key. Rotation remains the answer to a leak.
- It is useless for a caller on dynamic egress (serverless without a NAT gateway, most PaaS dynos, a home connection). Such a customer should leave the list empty rather than lock their own integration out.
The control gates API keys only. It never touches the browser session, the workspace UI or the signing pages: a customer who mistypes an address can always sign in and clear the list, and a signer is never refused because of something their counterparty typed. Full contract in Authentication.
The address we compare is the one our platform observed the connection
coming from, read from the platform's own request header. We never derive it
from X-Forwarded-For, which is a hop-by-hop breadcrumb any client can write
for itself — an access control that trusted it would be defeated by one extra
header line, which is worse than no control at all because it looks like
protection.
Origin is not an access control for the REST API
We are asked this often, so here it is plainly: Origin does not authorise
a call to api.wesign.now, and we will not add an origin allowlist there.
Origin is a header a browser attaches and refuses to let page script
forge. That guarantee exists only inside a browser. A server-to-server caller
— which is what every REST integration is — writes whatever Origin it
likes with one line of curl. So an origin allowlist on the REST API would
stop nobody holding a stolen key while advertising that it did. The
server-to-server equivalent that is actually enforceable is the IP allowlist
above, because the address is observed by our edge rather than asserted by
the caller.
Embedded signing is the opposite case, and there we do pin origins — because there a browser is genuinely in the loop and enforces the boundary for us:
- each session is minted for exactly one https origin, named by your
backend; a key restricted to a list of embed origins refuses any other with
400 origin_not_allowedat mint time (an empty list allows any https origin); - each session's embed page answers with
Content-Security-Policy: frame-ancestors <that session's one origin>, so the browser refuses to render the frame anywhere else — and defaults toframe-ancestors 'none'if the session can't be read; - the frame's
postMessagecalls target that exact origin, never'*'.
See Embedded signing.
Data residency
Each workspace pins its documents — uploads, signed PDFs and their audit-trail certificates — to one Blob region: EU (Frankfurt, default), Switzerland (Zürich), UK (London) or US (Washington, D.C.). For every region, accounts and audit events are kept in our database in Ireland (EU). Switching after the fact moves NEW uploads only; historical documents stay where they were created (avoids cross-region copy costs + the multi-minute latency that move would imply).
Not-yet shipped
Honest list:
- Customer-managed keys (BYOK). Workspace-level, with the master
key held in the customer's KMS. Schema sketch is in place
(
workspaces.encryption_key_id); the integration with AWS KMS / GCP KMS / Azure Key Vault is not. - Master-key rotation. When v2 ships, a backfill cron will rewrap v1 data keys without re-encrypting the files themselves. Until then, rotation is a planned outage.
- HSM-backed master key. Today the master is an env-var; an HSM would prevent operator extraction. Worth doing for buyers in finance / pharma / healthcare.
- Encryption of browser-direct uploads. See the callout at the top — the in-app dashboard upload path bypasses our server. A client-side encryption shim would close that gap; not currently shipped.
- Envelope encryption of template-rendered PDFs. See the callout at the top; they carry Vercel's vendor encryption only.
- FIPS 140-2 / 140-3 validation. AES-256-GCM is current
best-practice but the implementation lives in Node's
cryptomodule, not a validated cryptographic module.
Reporting a vulnerability
Email security@wesign.now. PGP key on request. We acknowledge within 24 h, ship a fix or mitigation within 7 days for high-severity issues, 30 days for medium. We don't pay bounties yet but will publicly credit you.
Rate limits
60 requests per minute per API key, counted on a shared counter. RateLimit-* headers on every successful response and every per-key 429, Retry-After on every 429.
How verification works
What /verify actually does when you drop a PDF — hash lookup, PAdES validation, eIDAS QES detection, the lot.
