Open-source e-signatures you host yourself.
Upload a PDF, place fields, send it to everyone who needs to sign, and get back a signed PDF with a certificate of completion and a full audit trail.
No pricing. No seats. Your documents never leave your server.
Quick start · Features · Configuration · Security · Contributing
Most teams only need the core of an e-signature product: send a PDF, collect signatures in the right order, and keep proof of who signed what and when. Countersign gives you exactly that as one small, self-hosted service.
| Own the data | PDFs, signatures and audit logs live in one volume on your server. Nothing is sent to a third party. |
| One command to run | A single Docker container with a built-in SQLite database. No database server, queue or object storage. |
| Free means free | No plans, no per-envelope limits, no locked features. |
| Signers need nothing | Recipients open a private link and sign in the browser on any device. No account, no download. |
|
Upload any PDF and click to place signature, initials, stamp, date signed, full name, email, company, job title, text, number, checkbox, dropdown and radio fields. Drag to move, resize from the corner, nudge with the arrow keys. Each recipient gets their own colour. Any number of signers, plus people who only receive a copy. Sign in parallel or in a set order; the next person is notified the moment the previous one finishes. Add a message and automatic email reminders, set the document to expire after a number of days, and optionally require each signer to enter a one-time code emailed to them before they can see or sign it. Or sign it yourself with no one else involved. Optionally send signers to an https address of your choice when they finish (they click to continue). Save a document with fields placed for roles like Client and Agency. Reuse it and only type names and emails. Bulk send one template to up to 500 people at once (type the rows or import a CSV), or turn on a public link so anyone with it can fill in their name and email and sign their own copy (useful for waivers and sign-up forms). |
A guided walkthrough jumps from field to field. Signers type a signature in one of five handwriting styles, draw it, or upload a photo or scan of their own (the background is removed in the browser). They adopt it once and reuse it everywhere. Account holders can save a signature and initials (stored encrypted) and reuse them whenever a signing link is addressed to them. Works with a mouse, trackpad, finger or keyboard, and on small phones. When the last person signs, every field is flattened into the PDF and a certificate of completion is appended: signers, timestamps, IP addresses, the original file's SHA-256 and the full audit trail. The finished PDF then gets a digital seal (a PKCS#7 signature over the whole file), so PDF readers can show whether it was changed afterwards. Anyone with a copy can check it on the public /verify page: the file is fingerprinted (SHA-256) in their browser and never uploaded. A dashboard with Needs your signature, Waiting for others, Completed and Drafts. Copy signing links, send reminders, let signers decline with a reason, and void a document at any time. Contacts fill in as you send (or import a CSV) and are suggested while you type recipients. Edit the invite and completion emails with placeholders such as |
![]() Documents dashboard |
![]() Placing fields for each signer |
![]() Signer view, no account needed |
![]() Certificate of completion in the signed PDF |
git clone https://github.com/cre8ive-marketing/Countersign.git
cd Countersign
cp .env.example .env # set APP_URL; SMTP is optional
docker compose up -dOpen http://localhost:3000 and create the first account. Sign-ups close after that unless ALLOW_SIGNUP=true.
All state lives in the countersign-data volume (/data in the container): countersign.db (SQLite) and files/ (original and signed PDFs, encrypted).
Important
Every PDF and every signature is encrypted with AES-256. Set your own key before you store real documents: openssl rand -hex 32 and put the result in ENCRYPTION_KEY in .env. Without it, Countersign generates encryption.key inside the data volume, which keeps backups of the files alone unreadable but does not protect against someone who has the whole volume. If you lose the key, every document is lost. Back it up separately from the data.
Needs Node.js 22.13 or newer (it uses the built-in node:sqlite).
npm ci
npm run build
npm startData goes to ./data unless DATA_DIR is set.
| Variable | Default | What it does |
|---|---|---|
APP_URL |
request host | Public URL used in emailed links, reminders, robots.txt and sitemap.xml. https:// turns on Secure cookies. Required for automatic reminders. |
DATA_DIR |
./data (/data in Docker) |
Where the database and PDFs are stored. |
ALLOW_SIGNUP |
false |
The first account can always sign up. Set true to let anyone create an account. |
ENCRYPTION_KEY |
generated file in DATA_DIR |
32-byte AES-256 key as 64 hex characters or base64. Encrypts every PDF and field value. Back it up; changing or losing it makes existing documents unreadable. |
TRUST_PROXY |
false |
Set true behind a reverse proxy so the audit trail, login limits , the /verify limit (60 checks an hour per address) and the public template link limit (10 copies an hour per address) use the client IP from X-Forwarded-For. Leave it off when clients connect directly. |
TOKEN_TTL_DAYS |
30 |
How long a signing link stays valid. Reminders and a signer's turn coming up extend it. |
SESSION_IDLE_MINUTES SESSION_MAX_DAYS |
60, 7 |
Sign-in sessions end after this much inactivity, or this long after sign-in. |
LOGIN_MAX_ATTEMPTS LOGIN_IP_MAX_ATTEMPTS |
5, 50 |
Failed sign-ins allowed per account from one address, and per address, within 15 minutes before a 15-minute lock. |
SIGNUP_MAX_PER_HOUR |
10 |
Sign-up attempts allowed per address per hour while sign-ups are open. |
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET |
none | Turn on single sign-on with one OpenID Connect provider. Also needs APP_URL. Register <APP_URL>/api/auth/oidc/callback as the redirect address. |
OIDC_NAME |
SSO |
Name on the button: "Continue with …". |
OIDC_ALLOWED_DOMAINS |
none | Comma-separated email domains allowed to use SSO. Set this with a public provider such as Google, or any Google account can try. With no users on the server yet, SSO won't create the first account without it. Create the first account before you expose the server. |
OIDC_AUTO_CREATE |
false |
Create accounts for new people on SSO while sign-ups are closed. Needs OIDC_ALLOWED_DOMAINS. |
OIDC_TRUST_EMAIL |
false |
Accept emails the provider does not mark as verified. Only for a provider you control, such as a single Microsoft Entra tenant. Anyone who can set an unverified address there could take over the matching account here. |
OIDC_REQUIRED |
false |
Turn off passwords where SSO can replace them. Accounts connected to SSO can't sign in with a password and connecting ends their password sessions, and new accounts come only through SSO. The first account and accounts not yet connected keep their password, so connect everyone first. |
STORAGE_QUOTA_MB |
1024 |
Original PDFs each account may store, counting bulk-send copies. 0 turns the limit off. Public-link copies have their own limit of the same size, so visitors can stop the link but not your uploads, and an account can use up to twice this. Copies that expire unsigned are deleted within the hour, keeping their audit trail. Declined and signed copies stay until you delete them. |
INVITE_EMAILS_PER_HOUR |
100 |
Team invite emails the whole server sends per hour. Past it, invites are still created and the link is shown to copy. 0 sends none. |
SMTP_HOST SMTP_PORT SMTP_SECURE SMTP_USER SMTP_PASS SMTP_FROM |
unset | Email delivery. Without SMTP, senders copy each signing link from the document page. Set APP_URL too: emails are not sent without it, because links built from the request's host could be forged. |
SIGNING_P12_BASE64 SIGNING_P12_PASSPHRASE |
unset | Your own certificate (PKCS#12, base64) for the digital seal on completed PDFs. Without it, Countersign generates a self-signed one once and stores it encrypted as signing.p12 in DATA_DIR. An invalid or expired certificate stops the server at startup. |
WEBHOOK_ALLOW_HTTP WEBHOOK_ALLOW_PRIVATE |
false |
Development and tests only. Allow webhook URLs on plain http://, and to private, loopback or link-local addresses. Leave both off in production. |
PORT |
3000 |
HTTP port. |
- HTTPS: run it behind Caddy, nginx or Traefik, set
APP_URL=https://…andTRUST_PROXY=true, and add HSTS at the proxy. Forwarded headers are ignored unlessTRUST_PROXY=true. If your proxy rewrites theHostheader and you leave it off, form submissions are rejected as cross-origin. - Backups: stop the container (or use
sqlite3 .backup) and copy the whole data volume. The database andfiles/must be backed up together. Back up the encryption key separately; a backup without it cannot be restored. - Upgrades:
git pull && docker compose up -d --build. The schema is created and extended on start. Existing unencrypted documents are encrypted once on the first start after upgrading. - Security logs: sign-ins, failed sign-ins, lockouts, password changes, expired links and file integrity failures are stored in the database and printed to stdout as JSON lines (
{"security_event": …},{"audit_head": …}). Ship stdout to your log system; theaudit_headlines let you detect if recent audit events are later removed from the database.
Create a key in Settings → API keys. It is shown once; send it as Authorization: Bearer cs_…. A key acts as your account on document and folder routes only. It cannot manage keys, webhooks, teams or your account, and it never returns signing links. In a team, a key has the same document rights as you (an admin's key can send, void and delete teammates' documents). Each key allows 600 requests an hour. A key is read-only or read and write (scope on POST /api/api-keys: read or write; default write, and the settings page defaults to read-only). A read-only key gets 403 on anything but GET. Keys expire after 30 days, 90 days or 1 year, or never (expiresDays on POST /api/api-keys; the settings page defaults to 90 days). Sign out of all devices can also revoke every key, and changing your password always does.
| Method and path | What it does |
|---|---|
GET /api/documents?status=&template=0|1&folder=&limit=&offset= |
List your documents and templates |
POST /api/documents |
Upload a PDF (Content-Type: application/pdf; add x-template: 1 for a template) |
GET PUT DELETE /api/documents/{id} |
Read, edit (drafts) or delete |
POST /api/documents/{id}/send void remind copy move |
Lifecycle actions (JSON body) |
POST /api/documents/{id}/bulk |
Bulk send from a template: JSON { "rows": [[{ "name", "email" }, …], …] } or a CSV body (name,email per role). Send an Idempotency-Key header so a retry cannot send twice. 10 calls an hour. |
GET /api/documents/{id}/file?kind=original|signed |
Download a PDF |
GET POST /api/folders, PUT DELETE /api/folders/{id} |
Folders |
Webhooks (Settings → Webhooks) send a JSON POST for document.sent, recipient.viewed, recipient.signed, recipient.declined, document.completed, document.declined, document.voided and document.expired. The body holds ids, title, status and the recipient's name, email and role, never files, field values or links. Deliveries that do not get a 2xx within 10 seconds are retried 6 more times over about 21 hours; redirects are not followed. After 20 deliveries fail every retry, the webhook is turned off and you get an email. Events can arrive out of order; use createdAt in the body. Webhook URLs must be https:// and may not point to private or internal addresses.
Each request carries Countersign-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> keyed with the whole whsec_… secret shown when you created the webhook. New secret replaces it; the old one stops working at once. Check it before trusting the body:
import crypto from 'node:crypto';
function verify(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSec;
return fresh && v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}flowchart LR
A[Sender uploads PDF] --> B[Place fields per recipient]
B --> C[Send]
C --> D{Signing order}
D -->|parallel| E[All signers get a private link]
D -->|sequential| F[Signer 1, then 2, then …]
E --> G[All signed]
F --> G
G --> H[Flattened PDF + certificate of completion]
H --> I[Everyone gets the signed copy]
| Layer | Technology |
|---|---|
| App | Next.js (Pages Router), React, plain JavaScript |
| Data | SQLite via Node's built-in node:sqlite, PDFs on local disk |
pdf-lib for stamping and certificates, pdfjs-dist for in-browser rendering |
|
Optional SMTP through nodemailer; hourly reminder job |
|
| Deploy | One Docker image (Next standalone, non-root) with one data volume |
- Encryption at rest: every PDF and every field value (signatures, initials, text) is encrypted with AES-256-GCM, bound to its document or field so files can't be swapped. Every download and every stamping run also checks the file against its recorded SHA-256 and refuses a mismatch.
- Accounts: passwords (at least 12 characters) are hashed with scrypt. Sessions are opaque
HttpOnly; SameSite=Laxcookies,Secureon https, stored only as hashes, and end after inactivity or a maximum age. Changing your password signs out your other devices, and Sign out of all devices ends every session. - Password reset: Forgot your password? emails a link that works once, for one hour, stored only as a hash. The answer is the same whether or not the account exists, and requests are limited per address and per account. Using the link ends every session, revokes all API keys, pauses webhooks and cancels pending team invites the account sent, and the owner gets an email saying so. Without SMTP, whoever runs the server makes a link with
node scripts/reset-link.mjs you@example.com(in Docker:docker compose exec countersign node scripts/reset-link.mjs you@example.com). Anyone who can read the account's mailbox can reset its password, unless the account is connected to SSO: connected accounts get no reset, so someone removed at the provider can't return through their mailbox, and they have no reset while the provider is down. WithOIDC_REQUIREDthis also covers accounts connected to an earlierOIDC_ISSUER. - Sign-in abuse: failed sign-ins are limited per account and address with temporary locks, and responses take the same time whether or not the account exists.
- Signing links: each signer gets an unguessable link, stored only as a hash plus an encrypted copy. Links expire, and voiding or declining a document revokes them. Signers see only their own fields, and only when it is their turn.
- Email codes (optional per document): the signer must enter a 6-digit code sent to their address before the page shows the document, its fields or the file. Codes last 10 minutes, allow 5 tries, and are stored only as keyed hashes; a verified browser gets a 12-hour
HttpOnlycookie for that signer only. A code shows access to the inbox, not who someone is. Needs SMTP andAPP_URL. - Digital seal: the completed PDF is signed with a certificate so any later change is detectable. A self-signed seal does not identify a person or company, and PDF readers may show its signer as unverified; use your own certificate if that matters.
- Expiry: past its expiry date a document can no longer be viewed, signed or declined, and its links are revoked.
- Requests: every state-changing request is checked for same origin, and every document read or write checks ownership on the server.
- API keys: stored only as keyed hashes, shown once, revocable, rate limited, and refused on account, key and webhook routes. Keys can expire, signing out of all devices can revoke them all, and a password change always revokes them all. Both also pause your webhooks until you turn them back on. Events during a pause are not sent later.
- Webhooks: secrets are stored encrypted. Each delivery resolves the host itself and refuses loopback, private, link-local and similar addresses at connect time, so DNS tricks cannot reach internal services.
- Public links: anyone with the link can create a copy, so the signer's name and email are only what they typed (the audit trail says so). Copies expire after a day unless the template sets an expiry, each address may submit 10 times an hour, and each link 50 times a day. Turning the link off or issuing a new one stops the old one.
- Single sign-on: OpenID Connect code flow with PKCE, tied to the browser by an encrypted cookie. The ID token comes straight from the provider's token endpoint over TLS, so its signature is not checked (OpenID Connect Core 3.1.3.7); issuer, audience, expiry and nonce are. A person is matched by provider subject. New people get an account by verified email, with a random password nobody knows. An existing account is never linked by email alone, because sign-up doesn't verify emails: its owner signs in, then connects SSO in Settings. A different subject for a linked email is refused.
- Teams: each person is in at most one team, as its owner, an admin or a member. Members can open and download the team's documents and use its templates; only the document's owner and the team's owner and admins can edit, send, void or delete; only the document's owner can move it, share it or change its public link. Signing links are never shown to members who can only read. Invite links work once, for one email address, for 7 days. While sign-ups are closed, only the server's first account can create a team, and only its invites can create new accounts. If the first account hands team ownership to someone else, that team's invites stop creating accounts too; people who already have an account can still join. Sharing a document with the team, or making it private again, is written to its audit trail. Leaving or being removed makes your team documents personal again.
- Audit trail: every action (upload, send, email, view, sign, decline, void, reminder) is recorded with time, IP and browser. Entries form a keyed hash chain per document. The certificate prints the chain head, the completion entry records both PDF hashes, and deleting a document keeps an encrypted record of its trail.
Countersign is software you run, so it can't hold certifications itself. ISO/IEC 27001, SOC 1 and SOC 2 (SSAE 18), CSA STAR and APEC PRP assess the organisation that operates a service. Countersign provides technical controls an operator can point to in those audits: encryption at rest, access control, session and sign-in protection, a tamper-evident audit trail and security event logs. Policies, risk assessment, access reviews, backups, monitoring, incident response and the audit itself remain the operator's job. Countersign stores no payment card data, so PCI DSS does not apply to it. The control-by-control mapping, including what is only partly covered, is in docs/security/control-mapping.md. Countersign makes no compliance or certification claims.
Known limits
- These are electronic signatures with an audit trail and a document seal. The seal is not a qualified or advanced signature of each signer, and it has no trusted timestamp (not PAdES-B-T/LTV). Sealing re-saves the PDF, so signatures already inside an uploaded PDF stop validating. Check what your documents and jurisdiction require. Countersign makes no legal-validity claims.
- Titles, names, email addresses and audit entries are not encrypted in the database, so the dashboard can list and search them. Protect the data volume with disk encryption and access control.
- Someone with write access to the database and the encryption key can still rewrite history. Ship the stdout logs off the server to keep an independent record.
- No two-factor sign-in, email verification or encryption-key rotation yet.
- Text typed into fields supports Latin-1 characters only (Devanagari or CJK print as
?). Signatures are images and are not affected. - Each document, including each bulk or public-link copy, stores its own encrypted copy of the PDF, so large templates sent to many people use a lot of disk.
- Single sign-on has one provider per server. Signing out here does not sign you out at the provider, password sign-in stays on, and people whose account was created by SSO have no password they know and no reset while connected. Turning someone off at the provider does not end their sessions or API keys here, and it stops their password only with
OIDC_REQUIREDon an account that has connected. An account keeps the email it started with if the address changes at the provider. If someone else already made an account with your email, SSO refuses you until that account is removed. ChangingOIDC_ISSUERdisconnects everyone: people with a password sign in and connect again, and accounts created by SSO use password reset. WithOIDC_REQUIREDon, reset refuses them, so turn it off for the move. While it is off, anyone who can read the mailbox of an account not yet reconnected can reset its password. - Not built yet: custom branding, SAML.
Found a vulnerability? Please report it privately as described in SECURITY.md.
npm install
npm run dev # http://localhost:3000
node scripts/sample-pdf.mjs sample.pdf # a two-page NDA to try the editor withnpm test runs the end-to-end tests over HTTP against a running server. Start that server with ALLOW_SIGNUP=true, SIGNUP_MAX_PER_HOUR=1000, a throwaway DATA_DIR and an explicit ENCRYPTION_KEY, and give the test process BASE_URL plus the same DATA_DIR and ENCRYPTION_KEY (and SERVER_LOG pointing at the server's stdout log). Tests that need those, or SMTP, skip themselves when they are missing.
pages/ UI routes and API routes (pages/api)
components/ React components (app shell, PDF viewer, signature pad, marketing)
lib/ database, auth, HTTP helpers, signing engine, PDF stamping, email
styles/ one global stylesheet with design tokens
test/ end-to-end tests
Contributions are welcome.
- Open an issue describing the bug or feature before large changes.
- Keep changes small and focused. Follow the existing structure, with no new frameworks or databases.
- Run
npm run buildandnpm testagainst a disposable server before opening a pull request. - New behaviour needs a test. Security-relevant changes (auth, links, uploads, audit trail) need a short note on what you checked.
The design system is documented in DESIGN.md. Product scope and principles are in PRODUCT.md.
Countersign is free software under the GNU Affero General Public License v3.0. You can use, modify and self-host it, including commercially. If you run a modified version as a network service, you must offer its source code to the people who use it.



