Documentation

Installing, configuring and running OpenWhistle, the free, self-hosted whistleblower platform for HinSchG and EU Directive 2019/1937.

Overview

A stateless, self-hosted web application: the secure internal reporting channel that the German Hinweisgeberschutzgesetz (HinSchG) and EU Directive 2019/1937 require. Licensed under the GNU General Public License v3.0.

Python and FastAPI, PostgreSQL 18 and Redis 8, shipped as a Docker image. No CDN and no third-party service; every outbound request is opt-in and listed in full.

Beside GlobaLeaks, SecureDrop and Hush Line: open source whistleblowing software compared.

Key design principles

  • Anonymity first: no IP address is stored at any layer (four layers).
  • Stateless: all session state lives in Redis; the app container is disposable and scales horizontally.
  • Compliance by design: the §17 HinSchG deadlines are part of the data model.
  • No lock-in: standard Docker Compose; your data is plain PostgreSQL.

Supported platforms

  • Any Linux host with Docker 24+ and Docker Compose v2
  • x86_64 and ARM64 (Apple Silicon): images for linux/amd64 and linux/arm64
  • Registries: GitHub Container Registry (ghcr.io), Docker Hub, Quay.io

Current version

v2.1.1. What it changed:

  • A new logo, and each theme tells the browser whether it is light or dark, so Chrome's Auto Dark Mode leaves the light theme alone.
  • Colours, code samples and the footer meet WCAG AA contrast in both themes.
  • No page is wider than a 360 px phone.

What v2.1.0 changed:

  • Every admin changes their own password and links their own single sign-on; a lost authenticator can be reset.
  • The §17 HinSchG deadlines follow the statute, from receipt, in calendar months.
  • Attachments lose their metadata without a pixel changing.
  • A superadmin gives each organisation its own admins.
  • Over forty fixes from a bug bounty, listed in the changelog.

What v2.0.1 changed:

  • The first account is a superadmin, so organisations can be managed.
  • Pages that fit the window no longer scroll past it.

What v2.0.0 changed:

  • The confidential identity is shown only to the handler, with an audited reason.
  • The first-run setup needs a one-time token.
  • Whistleblower times are stored as the day only.
  • An optional Tor onion address and ClamAV upload scan.
  • TLS by default in the Compose stack.
  • An opt-in installation count, off by default.
  • Words inside reports are searchable without an index.

Every change: Changelog.

Requirements

A shared VPS with 1 vCPU and 1 GB RAM is enough for most organisations.

NeedMinimum
Docker24.0 or newer
Docker Composev2.0 or newer: the docker compose plugin, not the legacy docker-compose binary
PostgreSQL 18, Redis 8bundled in the Compose stack, nothing to install
RAM512 MB (1 GB recommended)
CPU1 vCPU
Disk5 GB for application, database and logs
Networka domain name, a valid HTTPS certificate (Let's Encrypt recommended), ports 80 and 443 open inbound for nginx
Important — HTTPS required

A reporting channel over plain HTTP breaks the confidentiality duty of §8 HinSchG and GDPR. In production, HTTPS is not optional.

Container Images

Multi-arch images (linux/amd64, linux/arm64) go to three registries on every release and every commit to main.

Registries

bash
# GitHub Container Registry (primary)
$ docker pull ghcr.io/openwhistle/openwhistle:latest

# Docker Hub
$ docker pull kermit1337/openwhistle:latest

# Quay.io
$ docker pull quay.io/jp1337/openwhistle:latest

Image tags

Tag Updated when Use for
latest The highest stable release tag is pushed; never a pre-release (v2.1.0-rc1) Production — always points to the current stable release
1.2.3 / 1.2 / 1 Release tag pushed; 1.2 and 1 move only to the highest release in their line Pinning to a specific version or minor series
edge Every push to main Testing the latest unreleased development state — not for production
sha-abc1234 Every push to main and every release tag Reproducing an exact build — useful for debugging and rollback
Pinning in production

docker-compose.prod.yml pins the image with OPENWHISTLE_VERSION in .env; its default is the release the file shipped with. The sha- tags suit immutable infrastructure pipelines.

Image signatures

Every GHCR image is signed with Cosign (keyless, Sigstore). Verify it before pulling in a security-sensitive environment:

bash
$ cosign verify ghcr.io/openwhistle/openwhistle:latest \
    --certificate-identity-regexp="https://github.com/openwhistle/OpenWhistle" \
    --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

Installation

Docker Compose starts everything with one command: application, PostgreSQL, Redis and nginx.

Step 1 — Clone the repository

bash
$ git clone https://github.com/openwhistle/OpenWhistle.git
$ cd OpenWhistle

Step 2 — Configure environment

At minimum set SECRET_KEY, DATABASE_URL and REDIS_URL; every other setting is in the configuration table.

bash
$ cp .env.example .env
$ nano .env

# The production stack (TLS, nginx); every docker compose command below uses it:
COMPOSE_FILE=docker-compose.prod.yml

# Minimum required values. The production Redis requires REDIS_PASSWORD,
# so it goes into REDIS_URL too, as POSTGRES_PASSWORD goes into DATABASE_URL:
# openssl rand -hex 32 — a new value for each key:
SECRET_KEY=<64 hex characters>
ENCRYPTION_KEY=<64 hex characters>
POSTGRES_PASSWORD=db-password
REDIS_PASSWORD=redis-password
DATABASE_URL=postgresql+asyncpg://openwhistle:db-password@db:5432/openwhistle
REDIS_URL=redis://:redis-password@redis:6379/0

Step 3 — Start services

bash
$ docker compose up -d
► Network openwhistle_default      Created
► Container openwhistle-db-1        Started
► Container openwhistle-redis-1     Started
► Container openwhistle-app-1       Started
► Container openwhistle-nginx-1     Started
TLS is on by default

nginx serves HTTPS on 443 and redirects plain HTTP on 80. Put your fullchain.pem and privkey.pem (e.g. from certbot) into nginx/certs/ and restart.

  • Copy them, do not symlink: a link into /etc/letsencrypt/live dangles inside the container.
  • Keep the key root-owned 0600, or 0644. Otherwise tls-init stops and names the path and the reason.
  • Without them, the one-shot tls-init service generates a self-signed certificate before nginx starts. It is for TLS_HOSTNAME (default localhost), a .env variable only docker-compose.prod.yml reads. After a change, docker compose up -d tls-init nginx issues and serves a new one.
  • To renew, drop the new certificate into nginx/certs/ and run docker compose up -d tls-init nginx. Restarting nginx alone keeps the old one: nginx never re-reads nginx/certs/ itself.
Self-signed is for first boot only

It lets the stack come up before you have a real certificate, not to serve users. A browser that clicks through its warning gets an HSTS pin (Strict-Transport-Security: max-age=31536000; includeSubDomains; preload) for that hostname. Install a real certificate before anyone but you visits.

Step 4 — Run the setup wizard

Unless you set SETUP_TOKEN yourself, read the one-time setup token from the log:

bash
$ docker compose logs app | grep "Setup token"

Then open https://<TLS_HOSTNAME>/setup (https://localhost/setup by default) and follow the First-Run Wizard. The account it creates is a superadmin: it manages organisations and grants every role.

Database migrations

Alembic migrations run automatically at startup. The app serves no traffic until every one has been applied.

Deployment privacy

Every request that leaves the host

The whole list; nothing on it is on until you switch it on. Three destinations are fixed — api.github.com, telemetry.wdkro.de and ClamAV's mirrors. Every other one is a server you name.

RequestSwitched on byDestinationHow oftenCarries
Update checkUPDATE_CHECK_ENABLED=trueapi.github.com — fixedat every start, then daily at 04:00 UTCa GET for the latest release; the User-Agent names the version
Installation countthe setup wizard or Admin → System, or TELEMETRY_ENABLED=truetelemetry.wdkro.de — fixedonce a daya random identifier and the version, in full below
EmailNOTIFY_EMAIL_ENABLED=trueNOTIFY_SMTP_HOST — yoursa digest every NOTIFICATION_BATCH_MINUTES; SLA reminders (REMINDER_ENABLED, checked every 30 min); a security alert at once on suspected password spraying; a reply notice when a case manager answerscounts and case numbers to your admins; a bare "you have a reply" to a whistleblower's own address
WebhookNOTIFY_WEBHOOK_ENABLED=trueNOTIFY_WEBHOOK_URL — yoursthe digest, SLA reminders and the security alert, as for emailcounts only, never a case number (Notifications)
Virus signaturesCOMPOSE_PROFILES=clamavClamAV's mirrors — fixed, from the clamav container's freshclam, not the appfreshclam's schedulenothing from OpenWhistle
Tor networkONION_LOCATION and the Tor daemon you run (Offering an onion address)Tor relays, from that daemon, not the appcontinuously while it runsthe hidden service's own circuits; nothing from OpenWhistle
Attachment storageSTORAGE_BACKEND=s3S3_ENDPOINT_URL — yourson upload and downloadencrypted attachments under random keys
Sign-inOIDC_ENABLED / LDAP_ENABLEDOIDC_SERVER_METADATA_URL / LDAP_SERVER — yoursat an admin sign-inthe admin's credentials, to your identity provider

None carries a whistleblower's words or files in readable form: attachments reach S3 encrypted, and notifications never include report content.

Through a proxy: the update check, the installation count, webhooks, OIDC and S3 honour HTTPS_PROXY, ALL_PROXY and NO_PROXY in the app container (SSL_CERT_FILE for a proxy's CA). SMTP and LDAP connect directly.

Counting installations

A security fix matters differently at ten installations than at ten thousand; this count is the only way to know which. The setup wizard asks, with the box unchecked. An installation upgraded to 2.0.0 was never asked and stays off. Switch it under Admin → System at any time, no restart.

What is sent, in full, once a day and nothing else:

http
GET /v1/openwhistle/count?id=<32 hex characters>&v=<version> HTTP/1.1
Host: telemetry.wdkro.de
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: openwhistle/<version>

Over HTTPS; no cookie, no body. The last four headers are what the HTTP library sends with every request (Accept-Encoding grows if a brotli or zstd package is ever installed).

Not sentthe hostname, any address, APP_PUBLIC_URL, any count of reports, users or organisations, any setting
The identifier16 random bytes made on your server when you first agree, and kept in the database. Admin → System shows it and Reset identifier replaces it: the next report is then a new installation as far as anyone can tell
Why randoma value derived from the hostname or a machine id could be recomputed by anyone who knows the host, which turns a count into a lookup
At the far endone line: timestamp, identifier, version. Your address is not recorded. Lines are kept 35 days, then only the rolled-up number
Whenchecked every hour, sent when 24 hours passed since the last success; the first attempt waits a random part of an hour after start. With several replicas only one sends (Redis lock)
A failure10 seconds for the whole request, redirects refused, one debug log line; retried at the next hourly check, never in a loop, never shown on a page
Never countedDEMO_MODE (the public demo) and LOCAL_REVIEW_LOGIN instances

TELEMETRY_ENABLED overrides the switch: false keeps it off, true on, and Admin → System shows it as set by the environment. Unset or empty (the Compose default), the switch decides.

The number is a lower bound and cannot be made tamper-proof: the endpoint is open, so anyone can invent identifiers. It tells ten installations from ten thousand, and shows whether a fix has spread; no more is claimed.

Redis sizing

A submission draft lives in Redis for up to 2 hours, Fernet-encrypted with a key that exists only in the whistleblower's cookie. Attachments make it large:

DraftRedis memory
Text only< 30 KB
5 × 10 MB attachments (maximum)about 90 MB

Set maxmemory with maxmemory-policy noeviction (docker-compose.prod.yml uses 1 GB): an evicting policy could drop sessions and rate-limit counters. Past DRAFT_REDIS_MEMORY_PERCENT, new draft attachments are refused so the rest of Redis keeps working.

Behind a TLS-terminating proxy

With Cloudflare, Traefik or a host nginx terminating TLS in front, choose one:

  • point it at https://…:443, with the bundled nginx's certificate, or
  • switch the bundled nginx to plain HTTP on port 80 with the shipped override. Port 80 then proxies instead of redirecting, and 443 is not published:
.env
COMPOSE_FILE=docker-compose.prod.yml:docker-compose.behind-proxy.yml
  • Keep port 80 reachable only from the terminator: nothing on it is encrypted.
  • SECURE_COOKIES stays true; the browser still talks HTTPS.
  • nginx still strips the client-IP headers the terminator adds, so it rate-limits the terminator's address. Rate-limit per client at the terminator.

SELinux hosts

Nothing to configure. With SELinux enforcing (Fedora, RHEL, CentOS and derivatives), the container must be allowed to read its bind-mounted files. So the mounts in docker-compose.prod.yml and the Ansible template (nginx.conf, nginx/certs, /etc/letsencrypt) carry the :z relabel option, a no-op elsewhere.

Offering an onion address

On a monitored network (a workplace, a school), a visit to OpenWhistle can be seen even though the report stays private. Tor Browser hides the visit too. Run a Tor hidden service on the same host, aimed at the onion-only nginx entry on 127.0.0.1:8080. It is published there only (the ports: entry in docker-compose.prod.yml).

bash
# 1. Install tor on the host, then add to /etc/tor/torrc:
HiddenServiceDir /var/lib/tor/openwhistle/
HiddenServicePort 80 127.0.0.1:8080

# 2. Restart tor, then read the generated address:
$ systemctl restart tor
$ cat /var/lib/tor/openwhistle/hostname
abc...xyz.onion

# 3. Set ONION_LOCATION in .env and restart the app:
ONION_LOCATION=http://abc...xyz.onion
$ docker compose up -d app

With ONION_LOCATION set:

  • Every HTML response carries an Onion-Location header; Tor Browser offers to switch.
  • The submit page shows the address, except to a visitor already on the onion service.
  • A request through the onion listener gets no HSTS and no Secure cookie flag. That connection was never TLS (Tor encrypts it), and a browser can refuse a Secure cookie over plain HTTP.
The app trusts nginx, not the client, to say "this is the onion listener"

Never the Host header: any client on the HTTPS listener could send Host: <anything>.onion. Instead the bundled nginx/nginx.conf sets X-OW-Onion: 1 only in the onion (port 8080) server block and clears it in the regular (443) one. It strips incoming IP-forwarding headers the same way.

So the app's own port (4009) must stay reachable only through this nginx, as IP stripping already requires. A deployment that exposes the app directly, or through another proxy, must set or clear X-OW-Onion itself. Otherwise neither protection holds.

Without ONION_LOCATION the app ignores X-OW-Onion, so a path that forwards it from the client, such as the Helm chart's ingress, is safe. With ONION_LOCATION behind ingress-nginx, clear it there with the annotation nginx.ingress.kubernetes.io/configuration-snippet: proxy_set_header X-OW-Onion ""; (the controller needs allow-snippet-annotations).

/var/lib/tor/openwhistle/ holds the address's private key

hs_ed25519_secret_key in that directory is the onion address. Lose it and the address changes for good, breaking every link a reporter was given. Whoever holds it can impersonate the service.

  • Back the directory up before any host migration or volume change.
  • Keep it root/tor-only: Tor sets 0700 on creation, but a bind-mount or a backup step can widen that. Check it.
  • Never commit or publish it, least of all wherever this how-to is shared.
Rate limiting on the onion listener

HiddenServicePort forwards every Tor visitor to nginx from 127.0.0.1, so per-IP limits cannot tell visitors apart. The onion server block therefore has its own zone, sized for several concurrent reporters. Heavy concurrent use still shares that one budget and can see a 429. If that happens, raise the zone's rate/burst in nginx/nginx.conf.

Kubernetes (Helm)

The ingress controller sees every whistleblower's IP address. The chart (charts/openwhistle/) sets nginx.ingress.kubernetes.io/enable-access-log: "false" on its Ingress. It also sets the bundled nginx's rate limit: limit-rps: "10", limit-burst-multiplier: "3", a burst of 30. It raises proxy-body-size to 55m, like the bundled nginx; ingress-nginx otherwise refuses any upload over 1 MB.

The chart bundles no PostgreSQL and no Redis: set secrets.databaseUrl and secrets.redisUrl to your own. A helm upgrade that changes a value restarts the pods; with autoscaling.enabled the HPA alone sets the replica count.

  • Your own ingress.annotations are merged with it; do not set it to null.
  • Required: error-log-level: crit in the controller ConfigMap. Below crit, ingress-nginx logs every rate-limited request and upstream error with the client's IP address.
  • A rate-limited request gets 503, not 429, unless the controller ConfigMap sets limit-req-status-code: "429" (controller-wide).
  • The limit is per peer address. Behind a load balancer the controller must see the real client, or every reporter shares one budget:
    • L4 (TCP, SNAT): proxy protocol (use-proxy-protocol: "true" in the controller ConfigMap, and on the load balancer), or externalTrafficPolicy: Local on the controller Service.
    • L7 (HTTP): use-forwarded-headers: "true" together with proxy-real-ip-cidr set to the load balancer's range. Without that range, any client sends its own X-Forwarded-For and escapes the limit.
  • ingress-nginx always forwards X-Forwarded-For and X-Real-IP. OpenWhistle drops them, and the peer address, in its first middleware. The admin dashboard still warns that the proxy knows the addresses.
  • Other controllers (Traefik: access log off unless enabled): check their logging.

With several replicas, only the pod that wins the race to create the setup token logs it: kubectl logs -l app.kubernetes.io/name=openwhistle | grep "Setup token" finds it. Setting secrets.setupToken (chart) / SETUP_TOKEN avoids the hunt: every replica uses that value.

A setting from the configuration table without a values.yaml key goes into extraEnv as NAME: value (for example SECURE_COOKIES: "false"). Secrets belong in secrets.

Configuration

Everything is an environment variable in .env. No YAML files and no settings in the database, except the admin preferences managed in the UI.

Variable Required Description Default
SECRET_KEY Required Signs admin sessions (and encrypts data when ENCRYPTION_KEY is unset). Minimum 32 characters (the app refuses to start otherwise). Generate with openssl rand -hex 32. —
ENCRYPTION_KEY Recommended Root key for all encryption at rest, ≥ 32 characters. Unset: SECRET_KEY is used for encryption too. On an existing install, do not just set it: follow Rotating the encryption key — the current key must go into ENCRYPTION_KEY_PREVIOUS. —
ENCRYPTION_KEY_PREVIOUS Optional Keys still accepted for decryption during a rotation: comma-separated, no spaces, each ≥ 32 characters. —
SETUP_TOKEN Optional Token the first-run wizard at /setup asks for, at least 32 characters (the app refuses to start with a shorter one; openssl rand -hex 16). After MAX_LOGIN_ATTEMPTS wrong tokens, /setup refuses every token for LOGIN_LOCKOUT_MINUTES. Unset: a random token is created at startup and written once to the log (docker compose logs app | grep "Setup token"). random
DATABASE_URL Required PostgreSQL connection string. Must use the async driver. Format: postgresql+asyncpg://user:password@host:port/dbname —
REDIS_URL Required Redis connection string. Format: redis://host:port/db, with a password redis://:password@host:port/db —
DRAFT_REDIS_MEMORY_PERCENT Optional New attachments in a submission draft are refused while Redis uses more than this share of its maxmemory; the whistleblower can still submit without them. No effect when Redis has no maxmemory. See Redis sizing. 80
APP_NAME Optional Display name shown in the application UI and browser title. OpenWhistle
DEMO_MODE Optional Set to true to enable demo mode. See Demo Mode section. false
LOCAL_REVIEW_LOGIN Optional Local review only — never in production. Requires DEMO_MODE=true, SECURE_COOKIES=false and a loopback APP_PUBLIC_URL (the app refuses to start otherwise). Shows a one-click "Enter the local review" button on /admin/login that signs in as the seeded demo admin with no password or MFA check, so an agent can review every admin page without a human typing credentials. Maintainer tooling for the release review: an operator never sets it. false
SECURE_COOKIES Optional Set to false when the app is served over plain HTTP (e.g. local network without TLS). Must be true behind HTTPS. Browsers block Secure cookies over HTTP, causing session failures when accessed from other devices. true
SESSION_MAX_HOURS Optional Absolute admin session lifetime in hours, counted from login; extending a session never passes it. 12
ACCESS_TOKEN_EXPIRE_MINUTES Optional Lifetime of one admin session token; Stay signed in renews it, up to SESSION_MAX_HOURS. 60
ALGORITHM Optional JWT signing algorithm of admin sessions. Leave it. HS256
MAX_ACCESS_ATTEMPTS Optional Wrong PINs for one case number before the status page asks the reporter to wait. A correct PIN always works. 5
ACCESS_LOCKOUT_MINUTES Optional How long that wait notice lasts. 15
MAX_LOGIN_ATTEMPTS Optional Wrong admin passwords for one username before that username is locked. 10
LOGIN_LOCKOUT_MINUTES Optional How long an admin username stays locked. 30
APP_VERSION Optional Informational; set by the image. Do not override. the image's version
ADMIN_FAILED_LOGIN_ALERT_THRESHOLD Optional Password-spraying alarm. Failed admin password attempts are counted across all accounts (no username, no IP stored). When this many fail within the window, one alert goes to the email and webhook notification channels and the audit log records auth.password_spraying_suspected. 0 disables it. Per-account lockout and MFA stay in force either way. 50
ADMIN_FAILED_LOGIN_ALERT_WINDOW_MINUTES Optional Sliding window for ADMIN_FAILED_LOGIN_ALERT_THRESHOLD, in minutes. At most one alert per window. 15
SUBMISSION_MODE_ENABLED Optional Set to false to hide the anonymous/confidential mode selection step from the submission wizard. When disabled, all reports are treated as anonymous. true
OIDC_ENABLED Optional Set to true to enable OIDC login for administrators. false
OIDC_SERVER_METADATA_URL Optional OIDC provider discovery URL (e.g. https://accounts.google.com/.well-known/openid-configuration). Required when OIDC_ENABLED=true. Login uses PKCE (S256) and accepts only an ID token signed with an asymmetric key from the provider's jwks_uri, with matching issuer, audience (the client ID), expiry and nonce. —
OIDC_CLIENT_ID Optional OAuth 2.0 client ID from your OIDC provider. Required when OIDC_ENABLED=true. —
OIDC_CLIENT_SECRET Optional OAuth 2.0 client secret from your OIDC provider. Required when OIDC_ENABLED=true. —
OIDC_REDIRECT_URI Optional OAuth 2.0 callback URL. Must match the redirect URI registered with your OIDC provider. Example: https://yourdomain.com/admin/oidc/callback. Required when OIDC_ENABLED=true. —
BRAND_PRIMARY_COLOR Optional Primary brand colour as a hex value. Used for buttons, accents, and links throughout the UI. #0c7253
BRAND_LOGO_URL Optional URL to a company logo image. When set, the logo is shown in the navigation bar instead of the default OpenWhistle shield icon. —
APP_PUBLIC_URL Optional Public base URL of the application. Used to build dashboard links inside notification emails and webhooks. Set to your domain (e.g. https://whistleblower.example.com). http://localhost
ONION_LOCATION Optional Tor onion (v3) address of this instance, e.g. http://<56 chars>.onion, no path or query string. When set, every response carries an Onion-Location header (Tor Browser offers to switch) and the submit page shows the address to reporters on a monitored network. Never sent to a visitor already on the onion service itself. See Offering an onion address. —
LOG_LEVEL Optional Logging verbosity. Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL. INFO
LOG_FORMAT Optional Log output format. Use json for structured JSON (recommended for log aggregation pipelines) or text for human-readable output. json
UPDATE_CHECK_ENABLED Optional Set to true to let OpenWhistle check GitHub once a day for a newer release, shown on the Admin → System page. Off by default (no external calls); sends no instance data to GitHub — only a standard request. false
TELEMETRY_ENABLED Optional The daily installation count. Unset or empty: the answer from the setup wizard / Admin → System decides, off until given. false: always off. true: always on. The switch is locked either way. DEMO_MODE is never counted. (empty)

Notifications

Email (SMTP) and webhook (HTTP POST) notices about new reports and whistleblower messages. Both are off by default and independent of each other.

ChannelGoes toCarries
Emailyour own adminsthe case number: only they can act on a specific case
Webhookthird parties (Slack, Teams, a generic endpoint)counts only, never a case number or a deadline date

Neither ever carries the report description, category or submission time.

Why notifications are batched

A notice sent the second a report arrives tells the employer when it was written, and who was at their desk then. So notices go out as one digest every NOTIFICATION_BATCH_MINUTES (default 1440, once a day at 00:00 UTC). Report times are stored as the day only; the digest interval bounds how precisely a notice dates a report. An hourly digest would say which hour. The trade-off: case managers learn of a report up to a day late, well inside the 7-day acknowledgement deadline. 0 sends each notice at once, only if you accept the timing risk.

VariableRequiredDescriptionDefault
NOTIFICATION_BATCH_MINUTES Optional Minutes between digests of new reports and whistleblower messages. All replicas fire at the same wall-clock moments; exactly one sends. 0 sends each event at once. 1440

Email (SMTP)

VariableRequiredDescriptionDefault
NOTIFY_EMAIL_ENABLED Optional Set to true to send an email notification when a new report arrives. false
NOTIFY_EMAIL_TO Optional Comma-separated list of recipient addresses (e.g. admin@example.com,compliance@example.com). —
NOTIFY_EMAIL_FROM Optional Sender (From) address for notification emails. openwhistle@localhost
NOTIFY_SMTP_HOST Optional Hostname of the SMTP server. localhost
NOTIFY_SMTP_PORT Optional SMTP port. Use 587 for STARTTLS or 465 for SMTPS. 587
NOTIFY_SMTP_USER Optional SMTP authentication username. Leave blank for unauthenticated relay. —
NOTIFY_SMTP_PASSWORD Optional SMTP authentication password. —
NOTIFY_SMTP_TLS Optional Use STARTTLS on the SMTP connection. Set to false when using SMTPS (port 465). true
NOTIFY_SMTP_SSL Optional Use direct TLS (SMTPS, port 465). When true, also set NOTIFY_SMTP_TLS=false. false

Webhook

A POST with a JSON body. With NOTIFY_WEBHOOK_SECRET set, an X-OpenWhistle-Signature: sha256=<hex> header lets the receiver verify it (HMAC-SHA256).

json
{
  "event": "new_activity",
  "new_reports": 2,
  "new_messages": 1,
  "message": "2 new reports, 1 case with new messages"
}

new_messages counts cases with new whistleblower messages, not the messages.

VariableRequiredDescriptionDefault
NOTIFY_WEBHOOK_ENABLED Optional Set to true to POST a JSON notification to a webhook URL on new reports. false
NOTIFY_WEBHOOK_URL Optional Target URL for webhook POST requests (e.g. a Slack incoming webhook or a custom endpoint). —
NOTIFY_WEBHOOK_SECRET Optional HMAC-SHA256 signing secret. When set, each request carries an X-OpenWhistle-Signature header for verification. —
NOTIFY_WEBHOOK_TYPE Optional Payload format for webhook notifications. generic sends {"event": "new_activity", "new_reports": 2, "new_messages": 1, "message": "..."} (new_messages: cases with new messages) (counts, never case numbers); slack sends a Slack Block Kit message; teams sends a Microsoft Teams Adaptive Card (v1.4). The SLA reminder webhook uses the same three formats with {"event": "sla_reminder", "ack_due": ..., "feedback_due": ..., "message": "..."} for generic. generic

SLA Reminders

Reminders before a HinSchG deadline runs out. The scheduler checks every open report every 30 minutes; Redis dedup keys send each warning once per window.

  • Email (your own admins): one per case, with its case number.
  • Webhook: one per run, with only the number of cases due. Never a case number or a deadline date.
VariableRequiredDescriptionDefault
REMINDER_ENABLED Optional Set to true to enable automatic SLA reminder notifications. false
REMINDER_ACK_WARN_DAYS Optional Send an acknowledgement reminder this many days before the 7-day deadline expires. 2
REMINDER_FEEDBACK_WARN_DAYS Optional Send a feedback reminder when this many days or fewer remain before the 3-month feedback deadline. 30

Data Retention (GDPR / HinSchG)

Closed reports are deleted automatically after a retention period. This satisfies GDPR Art. 5(1)(e) (storage limitation) and HinSchG §11 Abs. 5 (documentation is deleted three years after the procedure ends).

  • Each deletion writes an immutable audit entry (report.auto_deleted) with the case number and legal basis.
  • /admin/retention shows the configuration and the next scheduled run.
  • On by default since v1.5.0. A report is deleted RETENTION_DAYS after it is closed. OpenWhistle was released in 2026, so no installation holds a report closed three years ago: turning retention on deletes nothing today.
VariableRequiredDescriptionDefault
RETENTION_ENABLED Optional Daily automatic deletion of closed reports older than RETENTION_DAYS. Set to false only on legal advice (e.g. pending litigation). true
RETENTION_DAYS Optional Days after a report is closed before it is automatically deleted. Default matches HinSchG §11 Abs. 5: documentation is deleted 3 years after the procedure ends. Change only on legal advice. 1095

Multi-Tenancy

One deployment can serve several independent organisations (tenants), each with its own reports, categories, locations and admin users. The superadmin role manages them at /admin/organisations.

Each organisation has its own reporting link, /submit/<slug> — the one to give its employees:

  • The wizard there offers only that organisation's categories and locations, and files the report under it.
  • The link, with a copy button, is on /admin/organisations (superadmin) and on an organisation admin's dashboard.
  • /submit is the default organisation's (DEFAULT_ORG_SLUG). If that names no active organisation, /submit answers 503 and logs the setting, and a set-up instance refuses to start.
  • An unknown or deactivated slug is a 404. There is no list of organisations, but a slug is part of a public link: anyone can test whether a guessed one exists.
  • A draft stays with the organisation it was started for. Another organisation's link shows a fresh wizard; its first step replaces the draft, and the earlier one is lost to that browser.
  • With multi-tenancy off there is one wizard, at /submit: /submit/<default slug> redirects there, any other slug is a 404.
  • Every account belongs to an organisation. On /admin/users a superadmin chooses it for a new account; an admin creates accounts in their own. Accounts LDAP provisions, and those made before multi-tenancy was switched on, belong to the default organisation.
VariableRequiredDescriptionDefault
MULTI_TENANCY_ENABLED Optional Set to true to activate multi-organisation support. Requires at least one organisation to exist; the default org is auto-created at setup. false
DEFAULT_ORG_SLUG Optional Slug of the default organisation auto-created during setup. Used for single-tenant deployments and as the fallback for legacy data. default

LDAP / Active Directory Login

Admins can also sign in with corporate LDAP. The first login creates an AdminUser (no local password) with the Case Manager role; an admin promotes it if needed. TOTP enrollment still follows, as for every account.

  • Accounts with a local password, such as the one the setup wizard created, keep signing in with it.
  • A directory user whose name a local account already has is refused, never merged into it.
  • The username comes from LDAP_ATTR_USERNAME. An entry without it is refused.

LDAP uses python-ldap (the OpenLDAP client) and S3 storage uses boto3: optional extras, both in the container image. From source, install libldap2-dev libsasl2-dev, then pip install '.[ldap,s3]'.

VariableRequiredDescriptionDefault
LDAP_ENABLED Optional Set to true to allow admin login via LDAP/AD. false
LDAP_SERVER Optional Hostname or IP of the LDAP server (e.g. ldap.example.com). —
LDAP_PORT Optional LDAP port. Use 389 with LDAP_START_TLS or 636 for LDAPS. Plain 389 without either sends passwords in clear. 389
LDAP_USE_SSL Optional Set to true to use LDAPS (TLS from the start). Set port to 636. The server certificate is verified against the system CA store; for a private CA, mount its PEM file and set LDAPTLS_CACERT to its path. false
LDAP_START_TLS Optional Set to true to upgrade a plain connection on port 389 with StartTLS before any bind, so neither the service account's nor the user's password crosses the network in clear. The certificate is verified exactly as for LDAPS; a server that refuses the upgrade makes the login fail. Ignored when LDAP_USE_SSL=true. false
LDAP_BIND_DN Optional Distinguished name of the service account used to search the directory (e.g. cn=svc-openwhistle,ou=service,dc=example,dc=com). —
LDAP_BIND_PASSWORD Optional Password for the service account bind DN. —
LDAP_BASE_DN Optional Search base for user lookups (e.g. ou=users,dc=example,dc=com). —
LDAP_USER_FILTER Optional LDAP search filter to locate a user entry. {username} is replaced with the entered username at runtime. (uid={username})
LDAP_ATTR_USERNAME Optional LDAP attribute to use as the username in the provisioned admin record. uid
LDAP_ATTR_EMAIL Optional LDAP attribute to read the user's email address from. mail

S3-Compatible Attachment Storage

Attachments are stored in PostgreSQL by default (STORAGE_BACKEND=db). STORAGE_BACKEND=s3 stores new ones in an S3-compatible bucket. Existing ones stay in the database and keep working.

VariableRequiredDescriptionDefault
STORAGE_BACKEND Optional Storage backend for new attachments. db stores data in PostgreSQL; s3 stores data in an S3-compatible bucket. db
S3_ENDPOINT_URL Optional Custom endpoint URL for S3-compatible services (e.g. MinIO, Hetzner Object Storage). Leave blank for AWS S3. —
S3_BUCKET_NAME Optional Name of the target S3 bucket. Required when STORAGE_BACKEND=s3. —
S3_ACCESS_KEY_ID Optional AWS access key ID (or MinIO equivalent). Required when STORAGE_BACKEND=s3. —
S3_SECRET_ACCESS_KEY Optional AWS secret access key (or MinIO equivalent). Required when STORAGE_BACKEND=s3. —
S3_REGION Optional AWS region for the bucket (e.g. eu-central-1). Ignored for custom endpoints. us-east-1
S3_PREFIX Optional Key prefix for all stored objects (e.g. attachments/). Useful when sharing a bucket with other applications. attachments/

File Attachments

Whistleblowers can attach evidence to a report. Files are stored with it, in PostgreSQL or in S3.

Scanning uploads for viruses

A ClamAV clamd daemon can scan attachments before they are stored. Off by default; to turn it on:

.env
COMPOSE_PROFILES=clamav
CLAMAV_HOST=clamav
  • docker-compose.prod.yml ships a clamav service behind the clamav profile, never reachable from the host.
  • It shares only the internal Docker network with app, not the proxy network, so nginx cannot reach it either.
  • Signature updates need outbound internet access, unlike the rest of the stack, so clamav gets its own egress network.
  • It needs roughly 1.5 GB RAM for the loaded signature database.

Scanning is fail-closed. With CLAMAV_HOST set, an upload is refused whenever clamd is unreachable or times out, exactly as if it were infected: nothing is ever stored unscanned. The whistleblower sees a generic "try again in a few minutes"; no file name, size or content reaches the log.

The Helm chart ships no clamav pod; the Compose profile has no Kubernetes equivalent. Setting config.clamavHost in values.yaml turns on the fail-closed behaviour for every upload: an unreachable clamd means refused, not skipped. Point it at a clamd you run and reach from the cluster, as its own Deployment and Service or an external instance. Leave it empty to keep scanning off.

VariableRequiredDescriptionDefault
CLAMAV_HOST Optional Hostname of the clamd daemon. Empty disables scanning entirely — no connection is ever made. —
CLAMAV_PORT Optional clamd's TCP port. 3310
CLAMAV_TIMEOUT_SECONDS Optional Connect and reply timeout for one scan. Exceeding it refuses the upload (fail closed). 30

Limits and allowed types

Size10 MB per attachment, before and after cleaning
GIF size50 megapixels, all frames together
Number5 per report
AllowedPDF, JPEG, PNG, GIF, WebP, TXT, CSV, DOCX, XLSX, checked by both MIME type and file extension
RefusedSVG, executables, archives and every other type. Legacy .doc/.xls too: their author cannot be removed, so save them as .docx/.xlsx and attach again

Access control

  • Whistleblower: /status/attachments/{id}, with an ow-status-session cookie tied to the report that owns the file. No other report's files are reachable. The session ends 2 hours after login and viewing does not extend it, so its remaining lifetime does not tell when the case was last opened.
  • Admin: /admin/reports/{report_id}/attachments/{id}, with an admin session; the file must belong to that report.

Every download carries Content-Disposition: attachment, so the browser never renders a file inline: no MIME sniffing, no script injection.

Privacy and deletion

  • Metadata removed on upload:
    • JPEG, PNG, WebP and GIF, also photos inside DOCX/XLSX: EXIF/GPS, camera data, XMP, ICC profiles and text chunks. Only the orientation tag stays.
    • JPEG, PNG and WebP are not re-encoded: pixels, colours and animation frames stay exactly as uploaded. Data after a JPEG's end (a motion-photo video) is removed.
    • PDF: document info, XMP on the document, its pages and images, comment authors and times, the EXIF of embedded JPEG photos, the file identifier. A PDF with files embedded in it is refused.
    • DOCX/XLSX: author, company and custom properties, SharePoint columns, the folder a workbook was saved in, the account name in template and link paths, revision-session ids and document variables.
    • A file that cannot be parsed for cleaning is refused. Plain text is stored as uploaded.
  • Office authors anonymised: comment and tracked-change authors, initials and account ids in DOCX/XLSX become Author. Embedded thumbnails, zip timestamps and zip extra fields are removed. A comment's text is content and stays, and Excel writes the author's name into it.
  • Filenames encrypted with the report's data key (older names by migration 003). S3 object keys are random ids, never the filename; objects stored before v1.5.0 keep their old key.
  • Encrypted at rest: file bytes are encrypted with the report's data key before they reach PostgreSQL or the S3 bucket. Attachments uploaded before v1.4.0 stay unencrypted and readable.
  • Deletion: a hard-deleted report (DSGVO Art. 17) loses its attachment rows by CASCADE DELETE, and its S3 objects are deleted too.

First-Run Wizard

On first boot, /setup serves the wizard: one page, four parts. Once an admin account exists it redirects to /admin/login for good.

Wizard steps

  1. Setup token
    From SETUP_TOKEN or the log (Installation, step 4).
  2. Admin account
    A username of 3–64 characters (letters, digits, spaces and . _ @ -). A password of at least 12 characters and at most 72 bytes.
  3. TOTP
    Scan the QR code with an authenticator app (Google Authenticator, Authy, Bitwarden, etc.) and enter its 6-digit code. Mandatory; it cannot be skipped.
  4. Installation count
    Optional and unchecked: may this installation be counted once a day (what is sent)? Changeable under Admin → System.
Save your TOTP secret

Write down the TOTP secret shown during setup and keep it offline. There are no backup codes. A lost authenticator is reset by a superadmin or on the host (Lost password or authenticator).

Admin Guide

The admin portal is at /admin, which leads to /admin/login.

Signing in

The reporting office sign-in: username, password and Continue. The reporting office sign-in: username, password and Continue.

Every account signs in with two factors. The first is a password (local or LDAP) or OIDC; the second is always a TOTP code.

Admin login: the first factor is a username and password checked against the database, LDAP, or an external OIDC identity provider. Then TOTP: an account without it enrolls first, which is mandatory. After the TOTP code, an account whose password someone else set chooses its own at /admin/account before the admin area opens. Admin login: the first factor is a username and password checked against the database, LDAP, or an external OIDC identity provider. Then TOTP: an account without it enrolls first, which is mandatory. After the TOTP code, an account whose password someone else set chooses its own at /admin/account before the admin area opens.
  • An account without TOTP yet (new from /admin/users, or first provisioned by LDAP) goes to /admin/mfa/setup after its first factor. Scan the QR code, confirm one code, and the session starts.
  • A password someone else set is replaced next, at /admin/account (Your account).
  • After MAX_LOGIN_ATTEMPTS wrong passwords, the username is locked for LOGIN_LOCKOUT_MINUTES from the last one. Upper and lower case count as one username.
  • A session lasts ACCESS_TOKEN_EXPIRE_MINUTES; Stay signed in renews it, up to SESSION_MAX_HOURS after login.

Dashboard overview

The reports dashboard: status and location filters, a search field, and the report table with case number, category, status, submitted day, assignee, 7-day SLA and 3-month SLA. The reports dashboard: status and location filters, a search field, and the report table with case number, category, status, submitted day, assignee, 7-day SLA and 3-month SLA.

/admin/dashboard lists every report you may see, with sortable columns, pagination and filters for status, assignee and location. Each status filter shows its case count within the chosen location; a case manager's counts cover only their own cases.

ColumnShows
Case numberOW-YYYY-NNNNN: the year and five random digits
Submittedthe day only (UTC), never the time
7-day SLADay N/7 until acknowledged: warning from day 5, overdue from day 7
3-month SLAdays left to feedback: warning at 14 or fewer, overdue once the date has passed

Roles

RoleMay
Superadmineverything an admin may, plus organisations (/admin/organisations). Sees every organisation, whatever its own org_id. The setup wizard creates one.
Admineverything, including users, categories, locations, the audit log and report deletion
Case Managerview, comment on and advance the reports assigned to them. The sidebar shows dashboard, statistics and telephone channel; no users, no deletion

Accounts are managed at /admin/users. A new account is a case manager unless another role is chosen; an unknown role is refused. The last active admin cannot be deactivated, so nobody is locked out. Only a superadmin creates, changes, deactivates or reactivates a superadmin. In DEMO_MODE the demo accounts cannot be changed. Only admins may dismiss the IP-header warning.

Your account

My account in the sidebar opens /admin/account, for every role. It shows the username, role, organisation and the ways the account signs in.

  • A password change needs the current password, the new one twice, and a current TOTP code. An open session alone is not enough.
  • The new password has 12 characters to 72 bytes and differs from the current one.
  • Wrong passwords and codes here count towards the sign-in lockout of the account.
  • After the change, every other session of the account ends. The one in use stays.
  • The audit log records auth.password_changed, never the password.
  • An LDAP or single sign-on account has no password here. It changes it in the directory or at the provider.
  • Link single sign-on and Unlink single sign-on are on this page (OIDC integration).
  • With DEMO_MODE, the demo accounts keep their password, so every visitor can sign in.

Whoever sets a password for someone else knows it. So its holder must replace it:

Password set byHolder's next login
an admin creating the account on /admin/usersenrol the authenticator at /admin/mfa/setup, then change the password
a superadmin's Reset authenticatorthe same, with the temporary password
the host's reset_admin_password.py --usernamechange the password

Until then, every other admin page leads back to /admin/account. Sign-out still works. The script cannot tell whether its operator is the holder, so it always asks for the change.

Lost password or authenticator

LostWho resets itHow
Authenticatora superadminReset authenticator on /admin/users
Authenticatorwhoever runs the hostpython scripts/reset_admin_password.py --reset-totp <username>
Passwordwhoever runs the hostpython scripts/reset_admin_password.py --username <username>
  • Run the script inside the container: docker exec -it openwhistle python scripts/reset_admin_password.py ….
  • Every reset ends every session of the account at once. After an authenticator reset, the old app stops working.
  • A password reset by the script is in the audit log as admin.password_reset. The next login must change it.
  • A reset in /admin/users makes the account new again: new authenticator, and a new temporary password in place of the old one.
  • The temporary password is shown once, to the superadmin. It is in no log and no audit entry. Hand it over in person.
  • With it, the next login goes to /admin/mfa/setup to enrol a new app, as for a new account. Then the holder chooses a new password (Your account).
  • An LDAP or single sign-on account has no local password. It keeps its directory or provider login and enrols a new app.
  • The script prints the new secret and its otpauth:// URI once. Hand them over in person.
  • A superadmin cannot reset their own authenticator in the browser: it would end their session and leave the account behind a password. Another superadmin or the script does it.
  • The script works for any account, the last superadmin included.
  • Every reset is in the audit log as admin.totp_reset. With DEMO_MODE, the demo accounts cannot be reset.

Managing reports

The search field on /admin/dashboard takes any part of a case number (for example 00042) or a word from the report.

  • A word search decrypts the description and messages of the reports you may see, in memory, for that request only. Nothing searchable is ever stored.
  • The confidential name is never matched: a hit would confirm an identity guess without an audited reveal.
  • The search is sent as a form (POST), so the term never appears in a URL, the browser history or a proxy's access log. Paging keeps it that way.
  • Every word search is audited (report.content_searched): the term, stored encrypted, and the number of matching reports.

Click a report to open its case page, /admin/reports/{report_id}:

A case page: the report, the communication thread, a reply box and internal notes; beside them the submission details and the actions acknowledge, assign, update status, export PDF and delete. A case page: the report, the communication thread, a reply box and internal notes; beside them the submission details and the actions acknowledge, assign, update status, export PDF and delete.
  • Read the report and its submission mode (anonymous / confidential).
  • Acknowledge report: tells the whistleblower and starts the 3-month feedback clock.
  • Move the status, assign the case to an admin or case manager, link related cases.
  • Reply to the whistleblower through the anonymous channel; add internal notes they never see.
  • Export a PDF with an SLA compliance section (HinSchG §17), the confidential identity left out. It prints Latin (any diacritic), Greek and Cyrillic text; CJK and right-to-left scripts (Arabic, Hebrew) are not supported.
  • Delete report, collapsed at the end of Actions: a second admin must confirm (4-eyes principle). It is the GDPR Art. 17 erasure.
    • Neither the requester nor an account the requester made (on /admin/users, directly or through others) may confirm, and the reverse: whoever made an account chose its first password.
    • A superadmin can reset another account's authenticator and act as it, so the rule holds between admins, not against a superadmin.
Case statuses: a submitted report is received. From received it goes to in_review or closed; from in_review to pending_feedback, closed or back to received; from pending_feedback to closed or in_review; a closed case can reopen to in_review. Received carries the 7-day acknowledgement deadline, pending_feedback the 3-month feedback deadline. Case statuses: a submitted report is received. From received it goes to in_review or closed; from in_review to pending_feedback, closed or back to received; from pending_feedback to closed or in_review; a closed case can reopen to in_review. Received carries the 7-day acknowledgement deadline, pending_feedback the 3-month feedback deadline.

Confidential identity. A confidential reporter's name and contact are hidden by default.

  • The case handler (while unassigned, an admin of the case's organisation) clicks Show identity (POST /admin/reports/{id}/identity). A reason is required: 10–500 characters, without the reporter's name.
  • The identity shows on that page only; the audit log records who, when and why.
  • The case page says only that a reason was recorded. The reason is on /admin/audit-log and in the CSV export.
  • Export PDF with identity (POST /admin/reports/{id}/export.pdf) asks for the same audited reason. The plain Export PDF never includes it.
  • Opening a case is logged as Report opened.

Audit log

  • /admin/audit-log: every admin action, immutable (HinSchG §11 Abs. 5), filterable, shown as readable labels.
  • Deleting a report deletes its entries with it. One entry stays, with the case number: Deletion confirmed (who requested, who confirmed) or report.auto_deleted.
  • CSV export at any time: the machine action codes plus a label column; the detail column is a JSON object. It holds every row the page's filters select, and taking it is itself recorded.
  • Downloading an attachment is recorded, as opening the case and exporting its PDF are.
  • Case views (Report opened) are hidden in the log and the export unless you tick Show case views (?views=1).
  • With multi-tenancy on, each organisation sees only its own entries. Instance-wide security events, such as a password-spraying alert, go to superadmins only.

Categories and locations

/admin/categories holds the report categories, all configurable. /admin/locations holds branches or offices; while any is active, the whistleblower wizard asks for one at step 2.

Dashboard statistics

/admin/stats: total reports, status distribution, category breakdown and the 7-day SLA compliance rate (acknowledged within 7 days of 00:00 UTC on the submission day). A case manager's figures cover only their own cases.

System & updates

System and updates: installed version and update status, the file integrity check, the installation count card and the two settings behind them. System and updates: installed version and update status, the file integrity check, the installation count card and the two settings behind them.
  • /admin/system shows the installed version. With UPDATE_CHECK_ENABLED=true (opt-in, off by default) it also says whether GitHub has a newer release: checked once a day, cached, no instance data sent.
  • Installation count: whether this installation is counted, the exact request, its identifier and the last successful report. An admin switches it (one audit entry per change) and resets the identifier; see Counting installations.
  • File integrity: the application files against a SHA-256 manifest baked into the image at build time. It reports missing, modified or unexpected files, which catches accidental changes, incomplete deployments and corruption. Re-check now refreshes it.

The integrity check is not tamper-proof: an attacker who can rewrite the files can rewrite the manifest too.

HinSchG deadline tracking

The statutory timelines of §17 HinSchG, shown on the dashboard, the case page, the whistleblower's status page and the PDF:

  • 7-day acknowledgement: counted from 00:00 UTC of the submission day. Times are stored as the day only, so the deadline can fall earlier, never later.
  • 3-month feedback: three calendar months from the acknowledgement; without one, three months and seven days from receipt (§17 Abs. 2). Every report has this deadline from the day it arrives, and an acknowledgement never moves it later. Feedback goes to the whistleblower through the message channel before it runs out.
  • A deadline is overdue once it has passed. On its last day it reads 0 days remaining.
  • The statistics' 7-day rate counts only the reports whose seven days are over or that were acknowledged.

Telephone reporting channel guide

/admin/telephone-channel is a compliance reference for an oral reporting channel beside the digital one:

  • HinSchG § 16 Abs. 3: reports orally and in text form; a dedicated number, confidentiality (§ 8), impartial staff
  • Implementation options: internal hotline vs. external ombudsman
  • § 11 Abs. 2 HinSchG: a call is recorded only with consent, otherwise documented as a summary
  • Integration workflow: how telephone reports enter the OpenWhistle case system
  • Legal references with links to the official statute text

Whistleblower Guide

The form is at the installation's root URL (e.g. https://your-domain.example.com/). No account, email address or personal information is needed.

Where several organisations share one installation, use the link your organisation gave you (/submit/<organisation>). Another link files the report with another organisation.

Submitting a report

Use a private browsing window and, where possible, a network not traceable to you (public Wi-Fi, Tor). The wizard keeps your progress in a temporary session for 2 hours; you can go back and forward at any step.

Step 1 of 6 of the submission wizard: a choice between Anonymous and Confidential, under the steps mode, location, category, details, files and review. Step 1 of 6 of the submission wizard: a choice between Anonymous and Confidential, under the steps mode, location, category, details, files and review.
Submission wizard: 1 mode, anonymous or confidential; 2 location, only if locations are configured; 3 category; 4 details; 5 files, optional; 6 review; then submit, which issues the case number and PIN once. Submission wizard: 1 mode, anonymous or confidential; 2 location, only if locations are configured; 3 category; 4 details; 5 files, optional; 6 review; then submit, which issues the case number and PIN once.
  1. Mode. Anonymous: no personal data ever stored. Confidential: name, contact details and an optional secure email, stored encrypted. The case handler sees them only after recording a reason. Both comply with HinSchG.
  2. Location (optional), only when the operator has configured branches or offices.
  3. Category (required) from the operator's list (e.g. financial fraud, safety violation, discrimination).
  4. Description (required), 10 to 10 000 characters. Be as specific as possible without naming yourself.
  5. Files (optional): PDF, images, Word (.docx), Excel (.xlsx), CSV, TXT, up to 10 MB each and 5 per report. Legacy .doc/.xls are refused; save them as .docx/.xlsx. Metadata is removed, and with virus scanning on, an infected file is refused. Going back keeps attached files; choosing new ones replaces them, and Remove drops one.
  6. Review and submit.
Step 6, the review: submission mode, location, category and description, with Back and Submit report securely. Step 6, the review: submission mode, location, category and description, with Back and Submit report securely.

Note down the case number and PIN at once. They are shown only once and cannot be recovered. Continue to report status unlocks only after you tick that you saved them.

Report submitted: the case number and the secret PIN, each with a copy button, a checkbox confirming they are saved, and Continue to report status. Report submitted: the case number and the secret PIN, each with a copy button, a checkbox confirming they are saved, and Continue to report status.
  • A second click on Submit never creates a second report. It shows the same case number and PIN, or, while the first click still runs, a still being processed page with Check again.
  • If the operator offers an onion address, the submit page shows it for use with Tor Browser.

Checking report status

The status page: case number and secret PIN fields and a View report status button. The status page: case number and secret PIN fields and a View report status button.

At /status, your case number and PIN show:

  • Current case status
  • Admin responses (if any)
  • HinSchG deadlines: the 7-day acknowledgement deadline and 3-month feedback deadline with days remaining
  • A form to send a follow-up message

Nobody can lock you out of your own case: the correct case number and PIN always work. After several wrong attempts on a case number the page asks you to wait, a notice, not a lock. Look up a different report ends the session on a shared device.

What is stored about when you acted

Only the day (UTC), never the time, of your report, your messages and your files. An exact time could be matched to who was at their desk. The reporting office's own replies keep their time. A message you send is stored as its day, but keeps its place among the office's replies, so the conversation reads in order.

Security recommendations for whistleblowers

  • Do not submit from a device or network that can be traced to you
  • Use a browser in private/incognito mode
  • Store the case number and PIN in a secure location not connected to your work identity
  • Never share the PIN with anyone

Security Architecture

Architecture: a browser reaches nginx over TLS on 443 (80 redirects), Tor Browser reaches the onion listener on 8080, bound to 127.0.0.1. Both lead to the stateless FastAPI app, which needs PostgreSQL and Redis, can use ClamAV and S3, and makes two opt-in outbound requests: the GitHub update check and the telemetry.wdkro.de count. Architecture: a browser reaches nginx over TLS on 443 (80 redirects), Tor Browser reaches the onion listener on 8080, bound to 127.0.0.1. Both lead to the stateless FastAPI app, which needs PostgreSQL and Redis, can use ClamAV and S3, and makes two opt-in outbound requests: the GitHub update check and the telemetry.wdkro.de count.

Defence in depth for anonymity: four independent layers keep IP addresses out of persistent storage.

The four anonymity layers

nginx — Strip the client-IP headers
X-Forwarded-For, X-Real-IP, Forwarded, X-Client-IP, X-Cluster-Client-IP, True-Client-IP and CF-Connecting-IP are cleared before a request reaches the app, even when a CDN added them. access_log off: request URIs never reach the disk. A reverse proxy of your own in front must not log them either.
Application middleware — Drop remote address
The first middleware removes those headers again and clears the peer address, before any route handler runs. No handler can read the real address, even if it tried. If such a header arrives anyway, the admin dashboard warns that a proxy in front knows the addresses.
Database schema — No IP column
No table has an IP address column: not reports, report_messages, audit_log or admin_users. The ORM cannot persist an address; that is structure, not policy.
Redis sessions — No identifying metadata
A whistleblower's status session is a random 256-bit token. Its Redis value is only the report's id, and it expires after 2 hours. No IP, no User-Agent, no browser fingerprint.

Rate limiting without IP tracking

  • Wrong PINs are counted per case number, under an HMAC of it, so a Redis dump does not list which cases someone tried. After MAX_ACCESS_ATTEMPTS (5) within ACCESS_LOCKOUT_MINUTES (15), the status page asks to wait. A correct PIN always works.
  • Admin passwords are counted per username (MAX_LOGIN_ATTEMPTS) and across all accounts, against password spraying (ADMIN_FAILED_LOGIN_ALERT_THRESHOLD).
  • nginx limits every route, the submission wizard (POST /submit) included: 10 requests/s with a burst of 30 per client address. The counters live in nginx memory only and are never logged. The onion listener has its own shared zone.
  • Helm: the chart sets the same limit on its ingress-nginx Ingress. With another ingress controller, set an equivalent one.

Security headers

The app sets them on every response, so they hold behind any proxy:

HeaderValue
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preload, except on the onion listener
Content-Security-Policydefault-src 'self'; scripts and styles only from self or with a per-response nonce, no 'unsafe-inline'
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policyno-referrer
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()

Onion listener trust boundary

With ONION_LOCATION set, the app decides per request whether it came through the onion (Tor) listener. That decides Secure cookies, HSTS and the Onion-Location header. The client-supplied Host header never decides it: a request on the HTTPS listener could claim Host: <anything>.onion. Instead, nginx sets X-OW-Onion: 1 only in the onion server block and clears it in the regular one, like the IP headers of layer 1. Both hold only while the app port is reachable through this nginx alone (Offering an onion address).

OIDC integration

With OIDC on, the identity provider is a second way past the first factor. It never replaces the second factor.

  • Login uses PKCE; the app checks the provider's signed ID token (see OIDC_SERVER_METADATA_URL in the configuration table).
  • Each person links their own account. Sign in with password and TOTP, then choose Link single sign-on on /admin/account.
  • The provider's sub and issuer are stored on the signed-in account, and nowhere else. The audit log records auth.sso_linked.
  • The link request is bound to that session. Finished in another browser or after sign-out, it links nothing.
  • An identity already linked to another account is refused. An unlinked identity cannot sign in.
  • Unlink single sign-on removes the link (auth.sso_unlinked). An account without a password cannot unlink its only way in.
  • The password keeps working. Every login still ends with TOTP, as for every account.

Demo Mode

For the public demo at demo.openwhistle.net, never for production.

Enabling demo mode

.env
DEMO_MODE=true

Demo mode behaviour

  • Prefilled admin credentials: username demo, password demo, TOTP code 000000
  • The static TOTP code 000000 is always accepted — no authenticator app needed
  • All reports and messages are visible to the demo admin
  • The password of the demo accounts cannot be changed, and their authenticator cannot be reset
  • A banner on every page says this is a demo instance
  • demo.openwhistle.net is wiped and restarted every 6 hours
Never submit real reports to the demo

The demo is public. Its data is wiped every 6 hours but readable by anyone until then. Never submit real or sensitive information to it.

Upgrading

Semantic versioning: minor and patch releases are backward-compatible. A major release may break things; its Changelog entry says what.

Standard upgrade procedure

bash
# Back up first: a rollback needs it
$ docker compose exec db pg_dump -U openwhistle openwhistle > backup.sql

# The compose file and nginx config change between releases
$ git pull

# Set OPENWHISTLE_VERSION in .env to the new version, then
$ docker compose pull
$ docker compose up -d

# Migrations run at startup; the version is in the health check
$ curl -sk https://localhost/health

Before upgrading

  • Read the Changelog for the target version.
  • v2.0.0 adds ENCRYPTION_KEY, a key for encryption separate from SECRET_KEY: see Rotating the encryption key.

Upgrading to 2.0.0 with Docker Compose

  • git pull is required, not only docker compose pull: the stack needs the new nginx/snippets/ and the tls-init service.
  • A customised nginx/nginx.conf makes git pull conflict: replace it with the shipped one (git checkout -- nginx/nginx.conf).
  • Certificates move from the old ./nginx/certs:/etc/nginx/certs mount to nginx/certs/ as fullchain.pem and privkey.pem, copied, not symlinked. Without them, a self-signed certificate is served.
  • Port 80 now only redirects to 443. Behind an external TLS terminator, see Behind a TLS-terminating proxy.
  • nginx also publishes 127.0.0.1:8080 (the onion listener). If another service on the host uses 8080, up fails: free the port first.

Rollback

The previous image refuses to start on a migrated database. Two ways back:

  • Restore the backup into an empty database and pin the old OPENWHISTLE_VERSION.
  • From 2.0.0 to 1.5.x: downgrade the schema with the 2.0.0 image first, then pin the old version:
bash
$ docker compose run --rm app alembic downgrade 7d4e2b9c1a05
# then set OPENWHISTLE_VERSION=1.5.0 in .env
$ docker compose up -d

The downgrade decrypts the TOTP secrets again and needs the same encryption keys. Migration 006 rounded whistleblower times to the day; the downgrade keeps them rounded, the exact times are gone by design.

Stay up to date

Security fixes are released as patch versions. To hear about them, watch the repository's releases (GitHub: Watch → Custom → Releases).

Rotating the encryption key

Re-encrypted: the per-report key wrappers, confidential identity and contact, secure e-mail, TOTP secrets, identity-reveal reasons. Report content and attachments are not touched. Back up the database first.

  1. Set ENCRYPTION_KEY_PREVIOUS to the key in use: the old ENCRYPTION_KEY, or SECRET_KEY on an install that never set one.
  2. Set ENCRYPTION_KEY to a new value (openssl rand -hex 32) and recreate the container: docker compose up -d (a restart keeps the old environment).
  3. Run docker compose exec app python scripts/rotate_encryption_key.py. It writes nothing unless every value can be re-encrypted; otherwise it lists the rows by id. Safe to run twice.
  4. Empty ENCRYPTION_KEY_PREVIOUS and recreate the container again: docker compose up -d.

At every start, before migrating, the app checks one stored report key and one TOTP secret. If no key opens them, it refuses to start and writes nothing; the log line names ENCRYPTION_KEY_PREVIOUS. That happens, for example, when ENCRYPTION_KEY is set on an existing install without step 1, or removed again.

Helm: before step 3, wait until every pod runs with the new environment (kubectl rollout status). During the rolling update, old pods cannot read data that new pods write.

Keep the old key

Until step 3 has succeeded, removing it leaves data unreadable. Afterwards, keep it safe as long as any backup from before the rotation is kept. To restore such a backup, put the old key back into ENCRYPTION_KEY_PREVIOUS and run the script.