Please report security issues privately through GitHub Security Advisories. Do not open a public issue for anything exploitable.
Include what you can: affected version, reproduction steps, and what an attacker gets out of it. A proof of concept helps but is not required to report something.
We aim to acknowledge within 3 working days and to ship a fix or a mitigation plan within 30 days for anything rated high or critical. You will be credited in the release notes unless you would rather not be.
| Version | Supported |
|---|---|
| 1.3.x | ✅ |
| 1.2.x | Security fixes only |
| < 1.2 | ❌ — these predate password reset and account lockout |
Because self-hosters carry the operational risk, it is worth being explicit about what the code does and does not protect you from.
- Passwords are hashed with Argon2id (64 MiB, t=3, p=2), rehashed transparently on sign-in when the parameters change.
- Two-factor authentication is TOTP, with single-use recovery codes stored as Argon2 hashes. The enrolment QR is an inline SVG — the shared secret is never sent to an image host.
- API tokens are stored as hashes only; lookup is by a non-secret prefix.
- Third-party OAuth tokens and TOTP secrets are Fernet-encrypted at rest
with
ENCRYPTION_KEY. Rotating that key does not destroy data — affected connections simply need re-authorizing. - A password reset or change revokes every existing session, so a reset evicts an attacker rather than running alongside them.
- Sign-in failures are counted per account, not per IP, because IP limits do nothing against credential stuffing spread across addresses. Second-factor failures count against the same lockout.
- The sign-in and password-reset endpoints return identical responses whether or not an account exists, and whether or not it is locked.
ALLOWED_HOSTSis validated on every request, because absolute URLs — reset links, OAuth redirects — are built from theHostheader.- Every
next=parameter is checked against an open-redirect allowlist. ProxyFixtrusts exactlyTRUSTED_PROXY_COUNThops. Trusting an unbounded number would let a client spoof its own source IP and defeat rate limiting.- Content Security Policy is
default-src 'self'. There are no CDN dependencies — every asset is served by the application. - All authored HTML passes through a
bleachallowlist before it is stored.
Every content row carries an organization_id, and all reads go through a small
set of scoped getters. Cross-tenant access returns 404, not 403 — a 403
would confirm the record exists. This is covered by tests for the web UI, the
API and the coverage report.
Privileged actions are recorded to an append-only log: sign-ins and failures, lockouts, password resets, session revocation, role changes, member removal, token issue and revocation, scope issue/revise/archive, MFA changes, and integration connect/disconnect. Entries outlive the deletion of their actor.
ProductionConfig refuses to start without SECRET_KEY, ENCRYPTION_KEY, a
non-SQLite database, and a working mail relay. A deployment that cannot send a
password reset is not a working deployment, so it will not boot pretending
otherwise.
- Anyone with database access can read your scopes and clause library. Only credentials and third-party tokens are encrypted at rest; document content is not. Use encryption at the storage layer if that matters.
- A malicious organization administrator. Admins can read everything in their own organization, issue tokens and change policy. The audit log records it, but nothing prevents it.
- Denial of service. Rate limits are per-account and per-endpoint, not a DDoS defence. Put it behind something that is.
- Supply chain. Dependencies are pinned by range and scanned by Dependabot
and
pip-auditin CI, but neither is a guarantee.
CI runs pip-audit, generates a CycloneDX SBOM as a build artifact, and
enforces ruff and mypy. The PDF rendering tests run for real against the
native stack rather than being skipped.
Before exposing this to anyone:
-
SECRET_KEYandENCRYPTION_KEYgenerated fresh, stored in a secret manager, never committed -
ALLOWED_HOSTSset to the hostnames you actually serve -
FORCE_HTTPS=1and TLS terminated in front -
TRUSTED_PROXY_COUNTset to the real number of proxies -
REGISTRATION_MODE=inviteorclosedfor an internal deployment -
RATELIMIT_STORAGE_URIpointed at Redis if you run more than one worker -
MAIL_SERVERconfigured and a test reset actually delivered - Two-factor required for the organization (Admin → Security)
- Database backups running, and a restore tested
An OIDC identity is matched to a local account by the issuer's subject first. A subject is issued by the provider and cannot be chosen by the person signing in, so matching on it is always safe.
Falling back to the email claim is not. Claiming a pre-existing account on an
email claim alone trusts the provider to have verified that address, and plenty
do not — a multi-tenant IdP with self-service signup will issue a token
asserting somebody else's address. ScopeMaker therefore refuses to link an
incoming identity to an existing account unless the provider reports
email_verified, and treats an absent claim as unverified rather than as
consent.
Creating a new account from an unverified address carries no such risk: it gets its own organization and can reach nothing that already exists.
OIDC_REQUIRE_VERIFIED_EMAIL=0 disables the check for an identity provider
that omits the claim entirely. Only set it for an issuer you operate.