On this page

Set it up · docs/authentication.md

Authentication

In server mode Countersign does not keep user accounts or passwords. People, their names and their roles come from your identity provider at sign-in. This page covers the four sign-in methods, sessions, the re-authentication behind an electronic signature, what the audit trail records about identity, and the error messages you may meet.

The Microsoft Entra ID and Keycloak steps below could not be tested against real tenants while writing this page. They describe what must be achieved at each step; names of portal screens are given "as of writing" and may have moved. Test the result in your own environment, in particular the points marked verify.

Contents

Methods at a glance

COUNTERSIGN_AUTH Who authenticates the person Session Re-authentication for approvals Use
entra (default) Microsoft Entra ID, OpenID Connect Signed cookie Yes, default 15 minutes Production with Microsoft 365 / Azure
oidc Any OpenID Connect provider (Keycloak, Okta, ADFS, …) Signed cookie Yes, default 15 minutes Production with another provider
proxy A reverse proxy in front of the application; it passes identity in headers None in Countersign No (the proxy's business) Environments that already authenticate at the edge
demo Nobody: the visitor picks a person from a list Signed cookie No Evaluation only

Without COUNTERSIGN_STORAGE there is no sign-in at all: that is the browser-only demo.

Both OpenID Connect methods use the authorization code flow with PKCE (S256), state and nonce. The ID token is verified with the provider's published keys: signature, issuer, audience (the client id), expiry (60 seconds of clock tolerance) and nonce. Countersign uses only the ID token; it calls no user-info or directory API.

How roles reach Countersign

Countersign needs three things about a person, all taken from claims of the ID token (or from proxy headers):

What Setting Default with entra Default with oidc Fallback
A stable id COUNTERSIGN_ID_CLAIM oid sub none: without the claim the sign-in is refused
A display name COUNTERSIGN_NAME_CLAIM name name preferred_username, then email, then the id
Roles COUNTERSIGN_ROLE_CLAIM roles groups none: the person can read and nothing else

The roles claim may be a JSON array of strings or one string with values separated by spaces or commas. It must be a top-level claim: a nested claim such as Keycloak's default realm_access.roles is not read.

Each value is used as a role id of the configuration (coordinator, qa, …; see Configuration). Where the provider's values are something else, translate them:

COUNTERSIGN_ROLE_MAP='{"SG-Fleet-QA":"qa","SG-Fleet-Coordinators":"coordinator","6f1c2a9e-…":"steward"}'

A value found in the map is replaced; a value not in the map is used as it is; values that match no role of the configuration are ignored by the engine. Several provider values may map to the same role, and a person may hold several roles.

Roles are read once, at sign-in, and stay in the session until it ends. A role granted or withdrawn at the provider applies from the person's next sign-in.

Microsoft Entra ID

What you will end up with:

COUNTERSIGN_STORAGE=postgres
COUNTERSIGN_AUTH=entra                         # the default
COUNTERSIGN_ENTRA_TENANT_ID=<directory (tenant) id, a GUID>
COUNTERSIGN_ENTRA_CLIENT_ID=<application (client) id, a GUID>
COUNTERSIGN_ENTRA_CLIENT_SECRET=<client secret value>
COUNTERSIGN_SESSION_SECRET=<at least 32 random characters>
COUNTERSIGN_PUBLIC_URL=https://records.example.com

The issuer is built from the tenant id: https://login.microsoftonline.com/<tenant id>/v2.0. Use the tenant's GUID, not a domain name and not common or organizations: the tokens name the tenant by its GUID, and the issuer must match exactly.

1. Register the application

In the Entra admin centre (as of writing: Identity › Applications › App registrations › New registration), create a registration for Countersign.

  • Supported account types: accounts in this organisational directory only (single tenant).
  • Redirect URI: platform Web, value <COUNTERSIGN_PUBLIC_URL>/auth/callback, for example https://records.example.com/auth/callback. It must match exactly, including scheme and path. For local development add http://localhost:3000/auth/callback.

Note the Application (client) ID and the Directory (tenant) ID from the overview.

You do not need the implicit grant or "ID tokens" under implicit and hybrid flows: Countersign receives the ID token from the token endpoint in exchange for the code.

2. Create a client secret

Under the registration's certificates and secrets, add a client secret and copy its value (not its id) into COUNTERSIGN_ENTRA_CLIENT_SECRET. Secrets expire: put the expiry date in your calendar, because sign-in stops when it passes (The identity provider did not issue a token: …). Countersign sends the secret in the body of the token request and does not support certificate credentials.

3. Define app roles whose Value equals the role ids

Under the registration's app roles, create one app role per role of your configuration:

Field Content
Display name Anything readable, such as Quality Assurance
Allowed member types Users/Groups
Value Exactly the role's id in the configuration: qa, coordinator, system-owner, …
Description What the role may do

The Value is what arrives in the token's roles claim, and Countersign compares it with the configuration's role ids character by character. If you cannot use the ids as values, set COUNTERSIGN_ROLE_MAP.

4. Assign people or groups to the roles

Roles are assigned on the enterprise application that belongs to the registration (as of writing: Identity › Applications › Enterprise applications › your application › Users and groups › Add user/group): choose a user or a group, then one of the app roles. Assigning groups to app roles needs an Entra ID licence level that supports it; without it, assign users one by one.

A person assigned to several roles gets all of them in the roles claim. A person with no role can sign in (unless you require assignment, next step) and can read only.

5. Require assignment

On the enterprise application's properties, set "Assignment required?" to Yes. Then only people who hold at least one app role can sign in at all; everyone else is turned away by Entra before Countersign sees them. Because every signed-in person can read every record, this is the control over who can read. Recommended.

6. Claims

  • oid is the person's object id in the tenant: stable, never reused, the same in every application of the tenant. Countersign uses it as the person's id in the audit trail. Leave COUNTERSIGN_ID_CLAIM at its default; sub is different per application and preferred_username or email can change.
  • name is delivered with the profile scope, which Countersign requests by default (COUNTERSIGN_OIDC_SCOPES=openid profile email).
  • roles is present when the person holds an app role.
  • auth_time: verify. Countersign reads auth_time from the ID token to know when the person last proved their identity. In Entra this is an optional claim that, as far as we know, must be added to the ID token under the registration's token configuration. If the claim is absent, Countersign falls back to the token's issue time, which is the time of the sign-in redirect, not of the last real authentication; see the section on signatures for why that matters and how to test it. Do not take this on trust: sign in, decode the ID token or look at an approval's audit entry, and compare.

Using groups instead of app roles

If your organisation manages access by security groups and does not want app roles:

  1. Add the groups claim to the ID token (token configuration › add groups claim). The claim then carries the groups' object ids.

  2. Set

    COUNTERSIGN_ROLE_CLAIM=groups
    COUNTERSIGN_ROLE_MAP='{"<object id of the QA group>":"qa","<object id of the coordinators group>":"coordinator"}'
    

Be aware of the group overage: for people in very many groups Entra leaves the groups claim out of the token and expects the application to ask Microsoft Graph instead. Countersign does not do that, so such a person would arrive without roles. Restricting the claim to groups assigned to the application avoids it. App roles do not have this problem and are the recommended way.

Check the result

  1. Open the application and sign in. GET /api/bootstrap shows who you are and with which roles: "user": {"id": "<oid>", "name": "…", "roles": ["qa"]}.
  2. Approve something and look at the entry: auth.method is entra, auth.issuer is your tenant's issuer, auth.authTime is the time you last authenticated.
  3. Wait longer than COUNTERSIGN_SIGN_MAX_AGE_MINUTES, approve again, and confirm that Entra asks for your credentials and that auth.authTime in the new entry is the time of that prompt.

Generic OpenID Connect

COUNTERSIGN_AUTH=oidc
COUNTERSIGN_OIDC_ISSUER=https://id.example.com/realms/company     # exactly as the tokens' "iss" claim
COUNTERSIGN_OIDC_CLIENT_ID=countersign
COUNTERSIGN_OIDC_CLIENT_SECRET=<secret>                           # optional: leave out for a public client with PKCE
COUNTERSIGN_ROLE_CLAIM=groups                                     # the default for oidc
COUNTERSIGN_SESSION_SECRET=<at least 32 random characters>
COUNTERSIGN_PUBLIC_URL=https://records.example.com

Requirements on the provider:

  • A discovery document at <issuer>/.well-known/openid-configuration with authorization_endpoint, token_endpoint and jwks_uri, reachable from the server.
  • The iss claim of its ID tokens equals COUNTERSIGN_OIDC_ISSUER exactly, character by character. The setting is used as you write it: if the provider's issuer ends with a slash (Auth0's https://<tenant>.auth0.com/), write the slash; if it does not, leave it out. Only the address of the discovery document is built without a doubled slash.
  • Authorization code flow with PKCE (S256).
  • If a client secret is set, the token endpoint must accept it in the request body (client_secret_post).
  • Redirect URI to register: <COUNTERSIGN_PUBLIC_URL>/auth/callback.
  • An ID token with a stable subject, a name and a top-level roles or groups claim.
  • For signatures: support for max_age=0 or prompt=login, and an auth_time claim in the ID token.

Keycloak example

In a realm (here company):

  1. Client. Create an OpenID Connect client with client id countersign. Turn client authentication on (a confidential client) and keep the standard flow; the other flows are not needed. Valid redirect URI: https://records.example.com/auth/callback (and http://localhost:3000/auth/callback for development). Copy the client secret from the client's credentials.

  2. Roles or groups in the ID token. Either

    • groups: add a mapper of type Group Membership to the client (its dedicated client scope), token claim name groups, full group path off, added to the ID token. Create groups named after the configuration's role ids (qa, coordinator, …) and put people in them; or
    • realm roles: add a mapper of type User Realm Role, token claim name roles, multivalued, added to the ID token, and set COUNTERSIGN_ROLE_CLAIM=roles. Create realm roles named after the role ids.

    A mapper is needed in both cases, because Keycloak's default places roles in the nested claim realm_access.roles, which Countersign does not read.

  3. Settings.

    COUNTERSIGN_AUTH=oidc
    COUNTERSIGN_OIDC_ISSUER=https://id.example.com/realms/company
    COUNTERSIGN_OIDC_CLIENT_ID=countersign
    COUNTERSIGN_OIDC_CLIENT_SECRET=<from the credentials tab>
    COUNTERSIGN_ROLE_CLAIM=groups
    

Keycloak includes auth_time in ID tokens and honours max_age and prompt=login. sub is the user's id in the realm and is stable. A local Keycloak for development is described in Developer setup.

Other providers

Provider Notes
Okta Issuer is the authorisation server's issuer (the org URL or …/oauth2/<server id>). Add a groups claim to the ID token (a claim named groups with a filter) and map group names with COUNTERSIGN_ROLE_MAP if they are not the role ids.
Auth0 The issuer ends with a slash: set COUNTERSIGN_OIDC_ISSUER with it. Roles need a custom, namespaced claim added by an Action; set COUNTERSIGN_ROLE_CLAIM to the claim's full name.
ADFS 2019 or newer, Google, others Any provider that meets the requirements above. A provider that can deliver no roles or groups claim (Google) leaves everyone read-only; put an authenticating proxy that adds groups in front instead.

These notes are generic; consult the provider's documentation for the current steps.

Authenticating reverse proxy

With COUNTERSIGN_AUTH=proxy, Countersign authenticates nobody itself. A reverse proxy in front of it signs people in and passes who they are in request headers.

COUNTERSIGN_AUTH=proxy
COUNTERSIGN_PROXY_SECRET=<at least 16 random characters>
# optional, shown with their defaults:
COUNTERSIGN_PROXY_USER_HEADER=x-forwarded-user
COUNTERSIGN_PROXY_NAME_HEADER=x-forwarded-preferred-username
COUNTERSIGN_PROXY_ROLES_HEADER=x-forwarded-groups
Header Content
X-Countersign-Proxy-Secret The value of COUNTERSIGN_PROXY_SECRET. Compared in constant time on every request. Without it, or with a wrong value, the request is treated as not signed in (HTTP 401 Sign in first.).
the user header The person's stable id. Required.
the name header The display name. If absent, the id is used.
the roles header Role ids or group names, separated by commas. COUNTERSIGN_ROLE_MAP applies.

The shared secret is what makes this safe: anyone who can reach the application directly could otherwise send X-Forwarded-User: someone-else. Therefore:

  • The proxy must set all four headers itself on every request and so overwrite anything the client sent.
  • Only the proxy may be able to reach the application's port (network policy, a private network, or a loopback listener).
  • Treat the proxy secret like a password and rotate it together on both sides.

No session cookie is involved; identity is read from the headers of each request, so role changes apply at once. The audit entry records auth.method: "proxy" without an authentication time or issuer. The re-authentication check for approvals is not applied with this method: if your procedures require a fresh authentication for a signature, enforce it at the proxy or use entra or oidc.

Example: oauth2-proxy with nginx

oauth2-proxy authenticates against your identity provider and answers nginx's auth_request; nginx passes the result on. Adapt to your versions; this sketch has not been run as part of writing this page.

server {
  listen 443 ssl;
  server_name records.example.com;

  location /oauth2/ {
    proxy_pass       http://127.0.0.1:4180;
    proxy_set_header Host                    $host;
    proxy_set_header X-Auth-Request-Redirect $request_uri;
  }

  location = /oauth2/auth {
    internal;
    proxy_pass              http://127.0.0.1:4180;
    proxy_set_header        Host           $host;
    proxy_set_header        Content-Length "";
    proxy_pass_request_body off;
  }

  # Monitoring may reach the health endpoint without signing in; restrict by network instead.
  location = /api/health {
    proxy_pass http://127.0.0.1:3000;
  }

  location / {
    auth_request /oauth2/auth;
    error_page 401 = /oauth2/sign_in;

    auth_request_set $user   $upstream_http_x_auth_request_user;
    auth_request_set $name   $upstream_http_x_auth_request_preferred_username;
    auth_request_set $groups $upstream_http_x_auth_request_groups;

    # Always set all four, so that nothing the client sent gets through.
    proxy_set_header X-Forwarded-User               $user;
    proxy_set_header X-Forwarded-Preferred-Username $name;
    proxy_set_header X-Forwarded-Groups             $groups;
    proxy_set_header X-Countersign-Proxy-Secret     "<the proxy secret>";

    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://127.0.0.1:3000;
  }
}

oauth2-proxy must run with --set-xauthrequest so that it returns the X-Auth-Request-* headers, and with a provider configuration that yields groups. Choose as the user header a value that is stable (a subject or object id rather than an e-mail address).

Demo sign-in

COUNTERSIGN_AUTH=demo
COUNTERSIGN_CONFIG=preset:fleet
COUNTERSIGN_DEMO_DATA=1          # optional

The sign-in page lists the people of the preset (Alice the coordinator, Kai from QA, …) and anyone can become any of them with one click: POST /auth/demo {"id": "kai"}. There is no password. Entries are recorded with auth.method: "demo".

  • Evaluation only. Never use it for real records. docker compose up uses it so that you can try the server mode without an identity provider.

  • By default the list comes from the preset whose id equals the configuration's id (fleet, property, clinic).

  • For a configuration of your own, or to replace a preset's list, name the people in COUNTERSIGN_DEMO_USERS: a JSON list of objects with id, name and roles (role ids of the configuration):

    COUNTERSIGN_CONFIG=./examples/config/lab-equipment.json
    COUNTERSIGN_DEMO_USERS='[{"id":"mia","name":"Mia Keller","roles":["metrologist"]},{"id":"noor","name":"Noor Haddad","roles":["lab-manager"]}]'
    

    Remember four eyes: list at least two people for everything you want to see approved. Without the variable and without a preset the list is empty and nobody can sign in. A value that is not such a list stops the server: COUNTERSIGN_DEMO_USERS must be JSON such as [{"id":"ann","name":"Ann Lee","roles":["qa"]}].

  • GET /api/bootstrap returns the list as demoPeople and the configuration as demoConfig, without sign-in, so that the sign-in page can show who holds which role.

  • The endpoint answers 404 Demo sign-in is off. with every other method.

Sessions

With entra, oidc and demo, a successful sign-in sets one cookie:

Property Value
Name countersign_session
Content <base64url(JSON)>.<base64url(HMAC-SHA256)>: the person's id, name and roles, the sign-in method, issuer and authentication time, and an expiry. Signed with COUNTERSIGN_SESSION_SECRET; not encrypted (it holds nothing the person does not know about themselves).
Attributes HttpOnly, SameSite=Lax, Path=/, Max-Age = the lifetime; Secure when the public address is HTTPS
Lifetime COUNTERSIGN_SESSION_HOURS, default 8. Fixed from sign-in; it is not extended by activity.

Consequences of keeping no session state on the server:

  • Any number of servers can serve the same person, as long as they share COUNTERSIGN_SESSION_SECRET.
  • Changing COUNTERSIGN_SESSION_SECRET signs everyone out at once, because no existing cookie verifies any more. That is also the way to end all sessions in an emergency. There is no way to end one person's session from the server before it expires; disable the person at the identity provider (which prevents the next sign-in) and, if needed, rotate the secret.
  • POST /auth/logout deletes the cookie in the browser. It does not sign the person out of the identity provider, and a copy of the cookie taken earlier would remain valid until its expiry.
  • Name and roles are those at sign-in. Shorten COUNTERSIGN_SESSION_HOURS if role changes must take effect faster.

During sign-in a second cookie, countersign_signin, holds the state, nonce and PKCE verifier for at most ten minutes; it is deleted when the sign-in completes or fails.

The secret must be at least 32 characters; the server refuses to serve otherwise. Generate one with openssl rand -base64 48.

Electronic signatures and re-authentication

An approval is the electronic signature in Countersign: a named person, at a recorded time, states what their signature means, on a version someone else wrote. To tie the signature to the person at the keyboard, an approval requires a recent authentication:

  • Setting: COUNTERSIGN_SIGN_MAX_AGE_MINUTES. Default 15 with entra and oidc. 0 turns the check off.
  • It applies to the approve command, with entra and oidc only. With proxy and demo the check is never applied, whatever the setting.

How it works:

  1. The server compares the authentication time in the session with its clock. If the sign-in is older than the limit, it refuses the approval with HTTP 401:

    {
      "error": "A signature needs a sign-in from the last 15 minutes. Sign in again, then approve.",
      "code": "reauth_required",
      "loginUrl": "/auth/login?fresh=1"
    }
    
  2. The application sends the person to that address. With fresh=1, Countersign adds max_age=0 and prompt=login to the authorization request: both tell the provider to authenticate the person now instead of reusing its own single sign-on session. max_age is the standard parameter; prompt=login is understood by providers that ignore max_age.

  3. The person enters their credentials (and second factor, if the provider asks) and comes back with a new session whose authentication time is now. If the provider answers a fresh=1 sign-in with a token whose auth_time is more than five minutes old, it evidently did not ask again, and Countersign refuses the sign-in: The identity provider did not ask you to sign in again (its authentication time is old).

  4. The person approves; the entry records how and when they were authenticated:

    "auth": { "method": "entra", "issuer": "https://login.microsoftonline.com/<tenant>/v2.0", "authTime": "2026-11-11T09:14:03.000Z" }
    

Where the authentication time comes from

authTime is the ID token's auth_time claim: the moment the provider last actually authenticated the person. If the provider does not put auth_time in the ID token, Countersign uses the token's issue time (iat) instead. That fallback is weaker:

  • The issue time is the time of the redirect through the provider. A provider that silently reuses its own session issues a token "now" without the person having typed anything.
  • With the fallback, an ordinary sign-in therefore always looks fresh, and an approval within the limit after it passes the check even if the person last entered a password hours ago.
  • The forced re-authentication of step 2 then relies entirely on the provider honouring max_age=0 / prompt=login: the five-minute check of step 3 can only be made when auth_time is present.

So, for every provider and in particular for Entra, verify in your own tenant:

  1. that the ID token carries auth_time (for Entra: the optional claim may need to be configured, see above);
  2. that a sign-in with fresh=1 really prompts for credentials;
  3. that auth.authTime in the resulting approval entry is the time of that prompt and not merely the time of the redirect.

Record the result in your validation documents (see the validation pack). If your provider cannot deliver auth_time, describe the fallback in your risk assessment or choose a shorter session lifetime.

What the signature consists of

Element Where it is recorded
Who actor (the stable id), actorName, actorRoles of the entry
When at, the server's clock, UTC
Meaning after.meaning and command.meaning: the text the approver gave
On what recordId, v; the version's data is in the entries that wrote it
How authenticated auth.method, auth.issuer, auth.authTime
That it was a second person Enforced by the engine; visible as different actor values on the version's entries
Integrity The entry's place in the hash chain

Countersign does not ask for a password itself and stores none; the "two components" of a signature are checked by the identity provider. Whether that meets the regulations you work under is for your validation to establish.

What the audit trail records about identity

Every entry holds, at the moment of the action:

Field Content
actor The stable id: Entra oid, the OIDC subject, the proxy's user header, a demo person's id, or what --actor-id said on the command line.
actorName The display name at that time.
actorRoles The role ids at that time, after COUNTERSIGN_ROLE_MAP.
auth.method entra, oidc, proxy, demo, cli, or system for the initial configuration.
auth.issuer The identity provider's issuer; for cli the operating-system account and host (jdoe@ops-01).
auth.authTime When the person authenticated (see above); for cli and demo the time of the command or sign-in.

These values are written by the server from the session or the proxy headers, never from the request body, and they are part of the hashed entry. A later change of someone's name or roles does not alter old entries. There is no user table: the list of people the application shows is everyone who appears in the trail, with the name and roles of their latest entry.

No e-mail address, password, token or IP address is stored.

Troubleshooting

Sign-in failures are shown on a page titled "Sign-in failed" (HTTP 400) or returned as JSON by the API.

Message Where Cause and remedy
The server is not configured completely. with a list such as COUNTERSIGN_ENTRA_TENANT_ID is required. any endpoint, HTTP 503 A required variable is missing. The list names each one; see Deployment.
COUNTERSIGN_SESSION_SECRET must be at least 32 characters (for example: openssl rand -base64 48). in that list Set a longer secret.
COUNTERSIGN_PROXY_SECRET (at least 16 characters) is required with proxy sign-in: … in that list Set the proxy secret.
Sign-in could not be started: The identity provider's discovery document could not be read (HTTP 404). /auth/login, HTTP 502 Wrong issuer or tenant id. Open <issuer>/.well-known/openid-configuration yourself.
Sign-in could not be started: fetch failed /auth/login, HTTP 502 The server cannot reach the provider: DNS, firewall, outbound proxy, TLS trust.
Sign-in could not be started: The identity provider's discovery document is incomplete. /auth/login, HTTP 502 The document lacks an authorization endpoint, token endpoint or key set.
A provider page complaining about the redirect URI (Entra: AADSTS50011) at the provider The registered redirect URI is not exactly <COUNTERSIGN_PUBLIC_URL>/auth/callback. Behind a proxy, set COUNTERSIGN_PUBLIC_URL.
This sign-in was not started in this browser, or it took longer than ten minutes. /auth/callback The countersign_signin cookie is missing: cookies blocked, another browser or host name, or a slow sign-in. Also happens when the callback's host differs from the one the sign-in started on (www. vs. none, http vs. https).
The sign-in took too long; start again. /auth/callback More than ten minutes between start and return.
The identity provider refused the sign-in: <description> /auth/callback The provider returned an error: the person cancelled, is not assigned to the application (AADSTS50105 when assignment is required), or consent is missing.
The sign-in response does not belong to this browser (state mismatch). /auth/callback An old or foreign response, often from the back button or two sign-ins in parallel. Start again.
The sign-in response has no code. /auth/callback The provider did not use the code flow. Check the client's allowed flows.
The identity provider did not issue a token: <description> /auth/callback The code exchange failed: wrong or expired client secret (AADSTS7000215, AADSTS7000222), redirect URI mismatch at the token endpoint, a client that does not accept the secret in the request body.
The ID token does not belong to this sign-in (nonce mismatch). /auth/callback A replayed response. Start again.
The ID token has no "oid" claim. (or the configured claim) /auth/callback The claim named by COUNTERSIGN_ID_CLAIM is not in the ID token. There is no fallback to another claim: a person's id must not silently change its source. Make the provider deliver the claim, or set the variable to the claim it does deliver.
The identity provider did not ask you to sign in again (its authentication time is old). /auth/callback, after a sign-in started with fresh=1 The provider returned an auth_time more than five minutes old: it reused its session instead of authenticating the person. Check that the provider honours max_age=0 and prompt=login for this client.
A message about an unexpected iss or aud claim, or a failed signature /auth/callback Raised by the token verification. iss: the issuer setting does not equal the token's issuer (domain instead of tenant GUID, a missing or extra trailing slash, a different realm). aud: the client id is wrong.
Sign in first. with "code": "signin_required" API, HTTP 401 No valid session: not signed in, expired, the session secret changed, or the servers do not share one secret. With proxy: the secret header is missing or wrong, or the user header is empty.
A signature needs a sign-in from the last 15 minutes. Sign in again, then approve. with "code": "reauth_required" /api/act, HTTP 401 Normal: re-authenticate. If it repeats right after re-authenticating, the server's clock is wrong.
The request is missing the X-Countersign-Request header. writes, HTTP 403 The caller did not send X-Countersign-Request: 1; see API.
The request comes from another site. writes, HTTP 403 The request's Origin is not the application's address. Behind a proxy: set COUNTERSIGN_PUBLIC_URL, or pass X-Forwarded-Proto and X-Forwarded-Host.
Demo sign-in is off. /auth/demo, HTTP 404 COUNTERSIGN_AUTH is not demo.
Unknown demo person. /auth/demo, HTTP 400 The id is not in the list: the preset's people, or COUNTERSIGN_DEMO_USERS. With a configuration of your own and no COUNTERSIGN_DEMO_USERS the list is empty.
Signed in, but every action says As someone without a role you can't … the application No role arrived, or none matches a role id. Look at user.roles in /api/bootstrap: empty means the claim is missing or named differently (COUNTERSIGN_ROLE_CLAIM); values that are not role ids need COUNTERSIGN_ROLE_MAP.
Signed out again after every sign-in, over HTTP in development the browser The cookie is marked Secure because COUNTERSIGN_PUBLIC_URL starts with https:// while you browse over http://.