Security
How letssign.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
Every document we write server-side is encrypted with a two-layer AES-256-GCM envelope on top of Vercel Blob's vendor-managed AES-256 encryption.
What's encrypted, what isn't:
The signing flow's server-generated artefacts — multipart uploads
through POST /v1/signing-requests, the PAdES-sealed signed PDF, the
DOCX→PDF conversion output — are envelope-encrypted. Browser-direct
uploads through the in-app dashboard (where bytes go straight from
your browser to Blob without our server seeing them) keep Vercel's
vendor encryption only. We're transparent about this because the
trade-off is real: the only way to encrypt those would be to add a
server-side roundtrip on every upload, doubling latency.
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 carries:
- PAdES-B-T signature — embedded directly in the PDF, verifiable by any compliant reader (Adobe Reader, Foxit, the EU's signature-validator).
- RFC 3161 trusted timestamp from an independent TSA — proves when the document was signed, by a third-party clock.
- Cumulative audit trail PDF — tamper-evident, downloadable separately, with cert serial + masked IPs + UA strings.
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.nowand its permanent aliasapi.letssign.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>")>.
The same value is also sent as X-LetsSign-Signature (the pre-rename
header, kept forever), alongside X-WeSign-Event-Id / X-WeSign-Event
and their X-LetsSign-* twins. 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). 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 defaults the other way — empty there means "cannot embed" — because it gates a capability nobody had yet.)
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:
- the origins allowed to frame a session are registered per API key, and an
unregistered one is refused
400 origin_not_allowedat mint time; - 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 to one Blob region — EU (default), Switzerland, UK, or US. 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.
- 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@letssign.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.
