Security measures
What pforte does about the web's usual attacks, and what it asks of the operator. For limits on trying to sign in see rate limits, for what is recorded the audit log, and for what is and is not defended the threat model.
Response headers
Every response, from the pages to the admin API and the OpenID endpoints, carries:
| Header | Why |
|---|---|
X-Content-Type-Options: nosniff |
A browser must not guess that data is a script or a page. |
Referrer-Policy: no-referrer |
An address with a token in it (the activation link) is not passed on when a link is followed. |
X-Frame-Options: DENY |
Nothing of pforte can be shown in a frame, so nothing can be clicked invisibly. |
Permissions-Policy |
Camera, microphone, location, payment and USB are switched off; pforte needs none. |
Strict-Transport-Security: max-age=31536000 |
Only when ISSUER is https: for a year browsers use https for this host only. It has no includeSubDomains and no preload: those would bind the other services on your domain, which is your decision. |
The pages with forms add a strict Content-Security-Policy: no scripts at all, styles only from the one stylesheet in
the page, fonts and the logo only from pforte itself, no framing, no <base>. They also send Cache-Control: no-store.
Two headers are left out on purpose:
Cross-Origin-Opener-Policy. Withsame-originan application that opens the sign-in in a popup loses its link to that popup, and popup sign-ins are part of OpenID Connect (display=popup).- A global Content-Security-Policy. The provider's own logout page needs inline styles and script. Our pages have their own policy.
Forms only from pforte's own pages
A form of pforte is only ever submitted from a page of pforte. A POST to the sign-in, code, reset or activation pages
whose browser says it came from somewhere else (Sec-Fetch-Site is not same-origin, or Origin is not the ISSUER)
is refused with 403 before anything happens: no password is checked, no code sent, no counter changed. A request that says
neither is not a browser (curl, a script) and cannot be tricked by a page, so it passes. This is on top of the
SameSite=Strict cookies of pforte's own steps, and needs no token in the forms and no JavaScript.
Links that lead to pforte from other places (the activation link in an e-mail) are GETs and are not affected. The
endpoints of the OpenID provider (/auth, /token, …) are called by applications from their own sites and are not
affected either.
Cookies
| Cookie | Holds | Flags |
|---|---|---|
pforte_second_factor |
The step between password and code: user, sign-in, language, time. Signed. | HttpOnly, Secure (https), SameSite=Strict, 15 minutes, only for that sign-in's path |
pforte_phone_step |
The step of confirming a phone after activation. Signed. | like the above, 30 minutes, path /activate |
pforte_device |
Tokens of remembered devices. Their hashes are in the database. | HttpOnly, Secure (https), SameSite=Strict, 90 days |
_session, _interaction, … |
The provider's own: the single sign-on session and the open sign-in. Signed with COOKIE_KEYS. |
HttpOnly, Secure (https). SameSite=None over https, because OpenID Connect requires the authorization endpoint to accept POST; Lax over http |
A test signs in over https and checks that every cookie has HttpOnly, Secure and a SameSite value.
Weak secrets stop a production start
With NODE_ENV=production pforte checks its configuration and refuses to start, listing every problem at once so they
can be fixed in one go (the messages say what to change, never what the secret is):
ISSUERis not https.- The password in
DATABASE_URLis empty or a well-known default (pforte,postgres,password, …). - A key in
COOKIE_KEYShas fewer than 12 different characters (kkkkkkkk…). - A signing key in
JWKSis RSA below 2048 bits, an EC key on another curve than P-256, P-384 or P-521, or another type. - Neither
SMTP_URLnorMAIL_FROMis set: every sign-in needs a code, so nobody could sign in. - A client still has the secret
dev-secret, or a user atexample.comthe passworddev-password(both come from the development seed). Rotate or delete them:POST /admin/clients/{id}/rotate-secret.
Without NODE_ENV=production the same findings are only a warning in the log at startup, so development stays easy.
Secrets come from the environment or from files (NAME_FILE, for Docker and Kubernetes secrets). Setting both is an error.
Logs
The request log hides the values of sensitive parameters in addresses: the token of an activation link, e-mail addresses
(email, login_hint), id_token_hint, codes and secrets appear as REDACTED. Passwords, codes and tokens are not logged
anywhere else either. Sign-in events in the audit log keep an address that no user has only as a hash.
Everything in pforte's log can contain client addresses (remoteAddress), which are personal data in many places. Keep the
logs as short as your obligations allow.
Dependencies
- Versions are pinned exactly and the lock file is installed as it is in CI (
--frozen-lockfile). - The workflow
audit.ymlrunspnpm auditon every push and pull request and every Monday. For what pforte ships (--prod) it fails on moderate or worse. A second run covers the development tools and only informs. - Dependabot proposes updates weekly. A new release waits 14 days before a pull request is opened, so one that turns out to be malicious or broken has time to be found; security fixes are not delayed. The pull requests of Dependabot are exempt from the sign-off check, as it cannot sign off.
- The image is built, started against a fresh Postgres and scanned by the workflow
image.ymlon every pull request and push and every Monday. The scan (Trivy) fails on high or critical findings that have a fix, in the operating system packages of the base image and in the Node packages. Accepted findings go into.trivyignore, each with its reason and a date to look at it again. The base image is pinned by digest, and Dependabot proposes new ones (after the same 14 days). - The repository protects
mainwith rules: changes arrive only through pull requests, which can be merged whencheck,dependencies,imageandsignoffhave passed, with linear history, and no force-push or deletion. Tagsv*, which start a release and publish the image, can only be created by administrators and cannot be moved or deleted without that right. Administrators can bypass the checks of a pull request in an emergency, but not push tomain. - Accepted advisory:
GHSA-67mh-4wv8-2f99(moderate), inesbuildthroughdrizzle-kit, a tool that writes database migrations during development. It is not part of what runs in production. It stays accepted untildrizzle-kitships a fix, and is checked again each time it is updated.