On this page

Understand it · docs/security.md

Security model

The security properties of Countersign in one place: the threats considered and the controls against them, how authentication, sessions and authorisation work, what is checked on input, what is logged, where secrets live, what the software depends on, and what it deliberately leaves to the database, the proxy and your procedures. To report a vulnerability, see SECURITY.md.

Contents

Scope and assumptions

Countersign's security goal is narrow and specific: no record changes without an identified author and a different, identified approver, and no change to the history goes unnoticed. Confidentiality between users of the same installation is not a goal (see Read access).

The model assumes:

  • a reverse proxy terminates TLS and the application is not reachable around it;
  • the identity provider authenticates people correctly and its administrators assign roles as approved;
  • the database's superusers and the hosts' administrators are trusted not to be casual attackers, and their actions are governed and logged outside Countersign; against them the software offers detection, not prevention;
  • server clocks are synchronised.

Threat model

# Threat Control Residual risk
1 A user approves their own change. The engine refuses an approval by the version's author or confirmer, for every role. Enforced on the server; re-checked by replay. Two people colluding. One person holding two accounts at the identity provider: an account-management matter.
2 A user acts beyond their role. Every command is checked by the engine against the configuration's permissions, on the server, with the roles from the session. The client's buttons are advisory only. Roles assigned wrongly at the identity provider. Roles are a snapshot at sign-in and last until the session ends.
3 A user forges who they are, or when they acted. Identity comes from the signed session or the proxy; the time from the server's clock; neither can be supplied in a request. A person using a colleague's unlocked, signed-in browser (narrowed for approvals by re-authentication).
4 A stolen session cookie. HttpOnly, Secure, SameSite=Lax; bounded lifetime; approvals need a recent authentication with entra and oidc. Until expiry the thief can read everything and issue non-approval commands as the victim. No per-session revocation; rotating the session secret ends all sessions.
5 A forged session cookie. HMAC-SHA256 over the content with a secret of at least 32 characters; constant-time comparison. Disclosure of COUNTERSIGN_SESSION_SECRET lets its holder mint any identity. Protect and rotate it.
6 Cross-site request forgery. Writes need the X-Countersign-Request: 1 header and, when present, a matching Origin; cookies are SameSite=Lax; no CORS headers. A cross-site scripting flaw in the application itself would bypass this, as in any web application.
7 Sign-in interception or replay. Authorization code flow with PKCE, state, nonce; ID token signature, issuer, audience and expiry verified; the flow cookie is signed and lives ten minutes. Weaknesses of the identity provider.
8 Signing with a stale authentication. Approvals require a sign-in no older than COUNTERSIGN_SIGN_MAX_AGE_MINUTES; re-authentication is requested with max_age=0 and prompt=login; a "fresh" sign-in whose auth_time is older than five minutes is refused. A provider that omits auth_time: the token's issue time is used and the check weakens. Verify per provider (Authentication). Not enforced with proxy and demo.
9 Spoofed identity headers (proxy sign-in). A shared secret in X-Countersign-Proxy-Secret, compared in constant time; without it the request is anonymous. Disclosure of the secret, or a proxy that passes client-supplied identity headers through. The application must be reachable only via the proxy.
10 The application (or an attacker inside it) alters or deletes history. The database role has INSERT and SELECT only; triggers refuse UPDATE, DELETE, TRUNCATE and rows that do not continue the chain. With the file store, or if the application connects as the table's owner, nothing prevents it; changes are still detected.
11 A database administrator edits an entry. Detected: the entry's hash no longer matches. verify, every server start, and clients' replay report it; servers refuse writes. Not prevented. A running server does not re-read old entries: detection comes with the next verify or restart. Schedule verify.
12 A database administrator rewrites the whole chain consistently, or cuts off the newest entries. Detected only by comparing with a head hash anchored outside the database (verify --expect-head, --expect-entry). Changes since the last anchor. Without anchoring: undetectable by the software.
13 A fabricated entry inserted directly into the database. Replay re-runs every command: an entry the rules would not have produced fails verification even with correct hashes. An entry that the rules would allow, written with someone else's name by a person with INSERT rights and the source code. Database access is the control.
14 Two servers fork the trail. Primary key and chain trigger admit exactly one entry per number; the loser retries on the new state. None known for PostgreSQL and SQLite. The file store has no cross-process locking: one process only.
15 Someone with shell access runs the CLI under a false name. Not prevented. The entry records the name given and the operating-system account and host; import and configure are unavailable over HTTP. Trust in operators. Restrict and log shell and database access.
16 Malicious content in records (script injection). Data is rendered as text by React; the sign-in error page escapes its message; the CSV export neutralises cells that start with =, +, -, @. A flaw in rendering. Configuration patterns are regular expressions written by the system owner: a pathological pattern can make validation slow.
17 Open redirect through sign-in. The return parameter must be a local path; anything else falls back to /app. None known.
18 Denial of service. Request bodies to /api/act are capped at 256 KiB; fields have length limits. No rate limiting in the application: do it at the proxy. Every server process and every browser holds the whole trail in memory (Deployment).
19 Unauthorised reading. Sign-in is required to read the trail; requiring assignment at the identity provider limits who can sign in. Every signed-in person reads everything. /api/health and /api/bootstrap answer without sign-in.
20 Tampering with the software or its configuration. The configuration is in the audit trail with its fingerprint; a change made in the application needs the configure permission and a reason, and takes effect only when a second signed-in person with that permission approves it; a change made in one step with the command-line tool needs a named reviewer. Whoever deploys the software can deploy a modified engine. Deployment is controlled by your change management; verify with an independently obtained copy of the software checks the data.
21 Loss of data. Append-only store; standard database backup; the trail can be exported and verified independently. Entries since the last backup.

Authentication

Details and set-up are in Authentication. In summary:

  • No passwords are stored or handled. People authenticate at the identity provider (OpenID Connect) or at a reverse proxy.
  • OpenID Connect: authorization code flow with PKCE (S256), state and nonce. The ID token's signature is verified against the provider's published keys with jose; issuer and audience must match the configuration; 60 seconds of clock tolerance. Only the ID token is used.
  • The person's id is the claim named by COUNTERSIGN_ID_CLAIM (oid for Entra, sub otherwise). If the claim is missing the sign-in is refused; there is no fallback to another claim.
  • The demo sign-in authenticates nobody and must never be enabled for real records.

Sessions

Property Value
Storage None on the server. The session is the cookie.
Cookie countersign_session; HttpOnly; SameSite=Lax; Secure when the public address is HTTPS; Path=/.
Content Id, name, roles, sign-in method, issuer, authentication time, expiry. Signed (HMAC-SHA256), not encrypted: the holder can read it, not change it.
Lifetime COUNTERSIGN_SESSION_HOURS (default 8), absolute, not extended by use.
Revocation Not per session. Signing out deletes the cookie in that browser only. Changing COUNTERSIGN_SESSION_SECRET invalidates every session at once.
Roles As delivered at sign-in. Changes at the identity provider apply at the next sign-in.
Signatures An approval additionally requires an authentication no older than COUNTERSIGN_SIGN_MAX_AGE_MINUTES (default 15) with entra and oidc.

Choose the session lifetime as the longest time you accept between a role being withdrawn and it ceasing to work.

Authorisation

  • One place. Every command, from the browser, the API or the command line, goes through act in lib/engine.ts. There is no other code path that changes state.
  • Server-side. The server runs the engine on its own verified state with the identity from the session. What the client believes is irrelevant.
  • From the configuration. A role's author and approve lists decide which record types and option levels it covers (Configuration). * never includes import or configure.
  • Four eyes is not configurable. The approver must differ from the author and the confirmer of the version.
  • Reserved for the command line. import and configure are refused over HTTP whatever the caller's roles; they need database credentials and shell access.
  • Re-checked. Replay re-runs every stored command under the roles recorded in its entry and the configuration in force at the time. An entry that could not have been authorised fails verification.
  • Unknown roles grant nothing. A person whose roles match no role of the configuration can read and nothing else.

Read access

Every signed-in person can read every record, every version and the entire audit trail. There is no restriction per record, per record type, per field or per level, and no role is needed to read: the browser receives the whole trail and computes the records itself.

What that means:

  • The audit trail includes all data before and after every change, every reason, and the names and roles of everyone who acted. All of it is visible to all users.
  • The only control over reading is who can sign in. With Entra, require assignment to the application; with other providers, restrict the client to a group; with a proxy, restrict there.
  • Do not run one installation for groups that must not see each other's records. Use separate installations (separate databases, separate application registrations).
  • For sensitive domains, decide before you start whether this is acceptable. The clinic preset shows patients as a record type to demonstrate the hierarchy with synthetic data; an installation holding real patient data, or other special categories of personal data, would give every user access to every patient's record and its history, and could not honour need-to-know within the system. Unless every user is entitled to see everything, keep such data out, or reference it by a pseudonymous identifier and hold the sensitive fields in a system with finer access control.
  • Erasure. Entries cannot be deleted or altered: that is the point of the system. A removed record disappears from the lists but stays in the trail for ever. Personal data that may be subject to erasure requests should therefore not be put into fields or reasons unless your retention rules for these records take precedence. The names and stable ids of users are part of every entry they wrote.
  • Reads are not recorded in the audit trail. If you need to know who looked at what, the proxy's access log shows who fetched the trail, not which record they opened.
  • /api/health (entry count, head hash, configuration name and fingerprint, storage kind, sign-in method) and /api/bootstrap (mode, storage kind, sign-in method; with demo sign-in the demo people and the configuration) answer without sign-in. They contain no record data. Restrict them at the proxy if that information should not be public.

CSRF and cross-origin requests

  • State-changing requests (POST /api/act, /auth/logout, /auth/demo) must carry X-Countersign-Request: 1. Browsers do not let another site's form set a custom header, and a cross-origin script would need a preflight that the server does not answer.
  • If the request has an Origin header, it must equal the application's public address.
  • The session cookie is SameSite=Lax: not sent on cross-site POSTs.
  • No Access-Control-Allow-* headers are sent. Another origin cannot read responses.
  • GET requests never change state. (GET /auth/login starts a sign-in, which a third party could trigger; it leads to the identity provider and back to a local path only.)
  • The application sends X-Frame-Options: DENY; add frame-ancestors 'none' at the proxy.

The origin check depends on the application knowing its address. Set COUNTERSIGN_PUBLIC_URL; otherwise the address is taken from X-Forwarded-Proto, X-Forwarded-Host and Host, which must then be set by the proxy and not by the client.

Input handling and limits

Input Limit or check
Body of POST /api/act At most 256 KiB (413 The request is too large.); must be JSON with a command object; NUL characters refused.
text fields, text options 300 characters.
longtext fields 4000 characters.
Notes, reasons, signature meanings Text, at most 2000 characters; reasons at least 3.
Record data Only the fields of the record type; each checked against its type, range, pattern, choices; references must point at existing, live records.
after of /api/entries A non-negative whole number.
return of /auth/login A local path, else /app.
Configuration documents Fully validated by checkConfig before use; identifiers restricted to slugs, keys and prefixes of fixed form.
Migration source files Parsed as data only; every row goes through the engine's validation.
SQL All statements are parameterised. No SQL is built from input.
HTML The application renders data as text. The Markdown documents shown under /docs are the repository's own files, not user input.
CSV export Cells are quoted; cells beginning with =, +, -, @, tab or carriage return are prefixed with '.

There is no upload of files and no rendering of user-supplied HTML or Markdown.

Integrity of the audit trail

Described in Architecture and Operations. The essentials for a security review:

  • SHA-256 hash chain over a canonical serialisation of each entry including its predecessor's hash.
  • Verification is by replay, not by hashes alone: each entry must also be what the engine produces for its command.
  • Verified on every server start, for every new entry while running, in every browser, and by the verify command.
  • On a fault the server keeps serving reads up to the fault and refuses all writes (HTTP 503).
  • PostgreSQL and SQLite refuse UPDATE and DELETE by trigger; PostgreSQL also TRUNCATE; the application role should hold no such privilege in the first place.
  • The chain gives tamper evidence. Evidence against a party who can rewrite the entire chain requires a head hash kept outside that party's reach.
  • The hash is not a signature: there is no private key, so anyone with the entries can recompute hashes. What ties an entry to a person is the server's authentication of that person at the time, recorded in the entry, and the protection of the store.

What is logged

Event Where it is recorded
Every accepted change: who, when, what, why, how authenticated The audit trail.
Configuration changes and imports The audit trail, with the operating-system account that ran the CLI.
Refused commands (permission, four eyes, validation) Not recorded by Countersign. Returned to the caller (HTTP 422); visible in the proxy's access log as a 422.
Sign-in, sign-out, failed sign-in, re-authentication The identity provider's sign-in log. Countersign keeps no record; a successful sign-in is visible indirectly through auth.authTime on the person's next entry.
Reads The proxy's access log (requests to /api/entries).
Store unreachable The server's log: countersign: the store could not be reached: <reason>. The reason is not returned to clients.
Integrity faults /api/health, the answers to writes, the fault field of /api/entries. Alert on them.
Direct database access The database's own logs.

If your procedures require a record of failed attempts, keep the proxy's access logs and the identity provider's logs for the required period. Countersign's logs contain no secrets and no record data.

Secrets

Secret Purpose If disclosed Rotation
COUNTERSIGN_SESSION_SECRET Signs session and sign-in-flow cookies. Anyone can forge a session for any identity and role. Change it on all servers at once. Everyone is signed out.
COUNTERSIGN_ENTRA_CLIENT_SECRET / COUNTERSIGN_OIDC_CLIENT_SECRET Authenticates the application to the identity provider's token endpoint. Combined with an intercepted authorization code (which PKCE already protects) it could be used to obtain tokens. Create a new secret at the provider, deploy it, delete the old one. Mind the expiry date.
COUNTERSIGN_PROXY_SECRET Proves that identity headers come from the proxy. Anyone who can reach the application can claim any identity. Change on the proxy and the application together.
The password in COUNTERSIGN_DATABASE_URL The application's database role. With the recommended role: read everything, append entries that pass the chain trigger. No deletion or change. Change the role's password and the variable.
The database owner's password Schema changes; can disable triggers. History can be altered (detectably; undetectably without anchors if the chain is rewritten). Keep it out of the application's environment entirely.

Rules: secrets come from the environment only; none is written to logs, responses or the audit trail; do not commit .env files; use different secrets per environment; the session secret must be at least 32 characters and the proxy secret at least 16, or the server refuses to serve. The docker-compose.yml in the repository contains a published evaluation secret and password: never reuse them.

Data at rest and in transit

Countersign delegates both.

Concern Who provides it
TLS for users The reverse proxy. The application speaks HTTP and marks cookies Secure when its public address is HTTPS.
TLS to the database The connection URI (sslmode=verify-full); see Storage.
TLS to the identity provider Node's HTTPS with the system's trust store. The issuer should be an https:// URL; nothing in the application enforces that.
Encryption at rest The database's or the volume's encryption. The SQLite and JSONL files are plain.
Backups Encrypt them; they contain everything.
Field-level encryption None. Every field is stored as readable JSON inside the entry, and must be, because every client replays the entries.
The browser In server mode the trail is held in the page's memory. In demo mode the visitor's own entries are kept in localStorage, unencrypted, on that device.

Exports (export, the reports of import, the downloads from the audit page) are plain files containing record data; protect them like the database.

Dependencies

Runtime dependencies of the server, as declared in package.json:

Package Used for
next, react, react-dom The web application.
pg The PostgreSQL store. Loaded only when COUNTERSIGN_STORAGE=postgres.
jose Verification of ID tokens (JWT, JWKS) in OpenID Connect sign-in.
marked Rendering the repository's own Markdown documents under /docs.

Built into Node.js and used directly: node:crypto (HMAC, random values, constant-time comparison, SHA-256 in the CLI), node:sqlite (the SQLite store), fetch.

The engine in lib/ has no dependencies at all, including for SHA-256 (lib/sha256.ts is a small synchronous implementation, because the engine must run identically and synchronously in browsers and in Node). It is compared against Node's implementation indirectly by the independent verification script in Architecture.

Development-only: TypeScript, ESLint, Tailwind CSS, type packages; Playwright for the optional walkthrough.

Keep dependencies current, watch advisories for next, pg and jose in particular, and treat a dependency update like any other change: run the tests and verify on a copy of your data (see Deployment). The exact versions in use are those of your package-lock.json; record them in your installation qualification.

The demo mode

Without COUNTERSIGN_STORAGE the application is a demonstration: no server state, no sign-in, every visitor has their own data in their own browser, and the API endpoints answer 404. Nothing a visitor does reaches the server or other visitors. It is safe to host publicly and useless for real records. Make sure production is not accidentally in this mode: /api/health then says "mode":"demo".

COUNTERSIGN_AUTH=demo is something else: a server with shared state where anyone can act as anyone. Evaluation only.

Known limitations

Stated plainly, so that they can be assessed:

  • No read restrictions of any kind among signed-in users.
  • No per-session revocation; roles are fixed for the life of a session.
  • No rate limiting, account lockout or anomaly detection in the application.
  • No record of reads, refused actions or sign-ins in the audit trail.
  • The re-authentication check depends on the identity provider's auth_time and is not applied with proxy sign-in.
  • The command-line tool trusts its operator's statement of who they are, and the reviewer of a configuration change applied in one step (config apply) is recorded by name, not authenticated. A change proposed and approved in the application is signed by two people the identity provider authenticated.
  • Tamper evidence against database superusers requires external anchoring, which is an operational task.
  • /api/health and /api/bootstrap are unauthenticated.
  • Signing out does not sign the person out of the identity provider.
  • No field-level encryption; no deletion of data.
  • The hash chain uses no digital signatures; entries are not individually signed by their authors.

Reporting vulnerabilities

Please report security problems privately, not in public issues. The process and contact are in SECURITY.md.