Skip to Content

Security Settings

The Security tab controls password rules, session lifetime, login lockout, and optional second-factor verification for admin accounts.

These settings apply to admin logins. Gallery passwords (the per-event password guests enter) are separate and have their own minimum-length rule (6 characters, no all-numeric).

Password rules

SettingDefaultWhat it does
Minimum length8Enforced at admin creation, password change, and password reset.
ComplexitystrongOne of simple, moderate, strong, very_strong. Each level adds requirements (uppercase, digit, symbol, no dictionary words).

Validation runs server-side in backend/src/utils/passwordValidation.js. Failed validations return a field-level error with the specific rule that failed.

Session

SettingDefaultWhat it does
Session timeout (minutes)60Admin is logged out after this many minutes of inactivity. Each request from the admin UI resets the timer.

Token refresh: while the user is active the JWT is silently rotated; the timeout above is the idle limit, not the absolute one.

The same idle limit applies when an admin previews a gallery (builds containing PicPeak PR #1497 ).

Lockout

PicPeak counts failed login attempts per (IP, email) pair and locks the pair out after the threshold:

SettingDefaultWhat it does
Max login attempts5Failures before lockout triggers.
Attempt window (minutes)15Sliding window over which failures are counted. Older failures fall out.
Lockout duration (minutes)30How long a locked-out pair stays blocked.

Lockouts log to auth_attempts and security_events. An admin can clear a lockout from the Users page.

In builds containing PicPeak PR #1809  the admin and customer-portal password forms apply this per pair as described, so failed attempts from one address no longer lock the account for everyone else. The second-factor step is counted differently: wrong codes are counted per account across all addresses, in a counter of its own, so changing address does not buy more guesses and failed password attempts by someone else cannot lock you out of the code prompt.

Two-factor authentication

Two-factor authentication is per admin, not a tab-wide switch. Each admin turns it on for their own account on the Two-factor authentication card under Settings → General (next to their account details), by enrolling a TOTP authenticator (Google Authenticator, 1Password, etc.). From then on every password login asks for a code.

Recovery codes are issued at enrolment — store them somewhere safe. Turning 2FA off and regenerating recovery codes both ask for a current code. Wrong codes on those two actions count against the same per-IP budget as failed logins (Failed logins per window, see API rate limiting), so a hijacked session cannot guess its way through (stable from v3.133.8, beta from v3.151.2-beta.0).

A password reset does not remove 2FA. For an admin who has lost both the authenticator and the recovery codes, run the break-glass script inside the backend container:

docker compose exec backend node scripts/reset-admin-mfa.js --email [email protected]

It clears that admin’s 2FA so they can sign in with their password and enrol again (--all --yes resets every admin).

Passkeys

Available on the beta channel from v3.171.0-beta.0; stable has no passkeys.

An admin can register passkeys (fingerprint, face, device PIN or a security key) on the Passkeys card under Settings → General, below the two-factor card. A passkey can be used two ways:

  • Instead of the password: Sign in with a passkey on the login page. No username is typed, and an unknown passkey gets the same refusal as a refused account.
  • Instead of the authenticator code: after the password, Use a passkey instead at the second step.

Rules:

  • Up to 10 passkeys per admin.
  • Adding one asks for the current password and, on an account with 2FA, a current authenticator or recovery code as well — a passkey signs in without the code, so the password alone is not enough to add one. Removing one asks for the current password. Wrong passwords or codes on add and remove, and failed passkey logins, count against the failed-logins budget.
  • Tied to the site’s address. The relying party ID is the host name of the configured public address (FRONTEND_URL, or the Site URL setting when that is not pinned), never the address in the browser. Passkeys are unavailable when the public address is an IP address, uses plain http on anything other than localhost, or is not configured, and the admin must open the admin UI on that exact origin. If the site moves to another domain, existing passkeys stop working and admins sign in with their password again.
  • Not for SSO accounts. Accounts that sign in through Admin SSO cannot register or use passkeys, and with password sign-in switched off in favour of SSO, passkeys are off too.
  • A passkey challenge issued before a restore’s session cutoff is refused. A portable (.picpeak) restore keeps the restoring admin’s own passkeys.

Password resets

A password reset is either a Super Admin resetting it on the Users page (Settings → Users on the beta channel, a sidebar entry on stable), or a command-line script that sets a password on an existing account, such as reset-admin-password.js. On a reset:

  • the admin must change the new password at the next login (a reset from the Users page emails a generated password)
  • every API token that admin created is revoked permanently — issue new ones after recovery (stable from v3.133.3, beta from v3.135.2-beta.0)
  • all of that admin’s passkeys are removed (beta from v3.171.0-beta.0); the activity log entry admin_password_reset records how many
  • 2FA stays as it is (see above)

An admin who changes their own password while signed in keeps their API tokens and passkeys. Accounts managed by SSO cannot be reset here; reset the password at the identity provider.

reCAPTCHA

Optional. When enabled, Google reCAPTCHA v3 is rendered on the admin login form (and any public registration form, if you have that on).

SettingDefaultWhat it does
Enable reCAPTCHAoffMaster toggle.
Site key(empty)Public key from Google reCAPTCHA admin console — embedded into the page.
Secret key(empty, masked in API)Private key — sent to Google for verification on every login.

The score threshold is hard-coded at 0.5 in the backend; below that the login is rejected with a generic “could not verify” error.

Gallery passwords and client PINs are stored as bcrypt hashes, so PicPeak cannot show you a password later. When a client loses it, the only way out has been Reset gallery password, which invalidates what the client already has.

SettingDefaultWhat it does
Keep gallery passwords recoverableoffStores an encrypted copy of each gallery password and client PIN next to the hash.

While it is on:

  • Create, Publish, Edit, Reset gallery password and the v1 API keep the copy in sync with the hash. Turning a gallery’s password requirement off deletes its copy.
  • The event page shows Show password with copy buttons for the gallery password and, if client access is on, the client PIN. Every reveal is written to the activity log as gallery_password_viewed.
  • Resend creation email sends the stored password instead of the “set at creation” placeholder, so the client receives what already works.

Switching it off deletes every stored copy. Only passwords set while it is on are kept, so an existing gallery shows “No stored password” until its password is reset once.

The copy is AES-256-GCM encrypted with a key derived from GALLERY_PASSWORD_ENCRYPTION_KEY, or from JWT_SECRET when that variable is not set. Anyone with the database and the server configuration, or with admin access to the event, can read the passwords. Changing the key makes the stored copies unreadable; switch the setting off and on again to start over. Login and hash verification never touch the copy.

This is available on stable from v3.132.1, and on beta from the build that ships PicPeak/picpeak#1271’s fix. On earlier builds the sent-mail archive under Messages still shows gallery passwords in clear text; from those builds on they are masked there.

API rate limiting

Independent of the login rules above, PicPeak limits how many API requests a single client IP may make. It exists to blunt scraping and credential stuffing, and it is worth knowing about because when it triggers it does not look like a limit: an affected visitor sees a gallery that stops loading, with no error anywhere they can see.

The API rate limiting card on this tab shows the values in force — the defaults below apply until you save something else — and saves with the tab’s Save Security Settings button. Stable builds from v3.132.1 and beta builds from PicPeak/picpeak#1338 onward have the card; older builds have only the API described under Changing the limits without the card.

SettingDefaultWhat it does
Enable the API rate limiteronMaster toggle.
Window (minutes)15Sliding window per client IP. 1–60.
Max requests per window300Budget for everything under /api/ that is not exempt (see below). Exceeding it returns 429 Too Many Requests with a Retry-After header for the rest of the window. 10–10000.
Failed logins per window5Separate, much smaller budget for admin login and gallery password verification. Only failed attempts count. 1–100.
Do not count authenticated requestsonRequests carrying a valid admin session do not count. Since v3.127.0-beta.0, a verified gallery viewer’s own image requests (thumbnail, preview, hero, photo) do not count either.
Count public endpoints onlyoffWhen on, only /api/public/* and /api/gallery/* are counted.

Saving applies at once: the limiter’s settings cache is cleared and the limiters are rebuilt with the new window, which also resets the current counters.

The unit is the IP address — not the person, and not the gallery. A photographer’s mental model is “one client, one gallery”; the budget applies to neither. Several people often share one address: a household, an office, a venue’s Wi-Fi and a mobile carrier’s NAT all present as a single IP, so a team of four reviewing a gallery from the same office draws on one budget of 300. Before v3.127.0-beta.0 every thumbnail counted, and a 546-photo grid exhausted the budget for a single viewer mid-scroll — the tiles that came back 429 rendered blank, and a refresh hit the limit again with the photo list. Since that version image requests of a verified viewer are exempt; a full gallery session then costs a few dozen counted requests, not hundreds.

What counts and what does not

Counted: every request to /api/ from a client without an admin session, including the gallery photo list, gallery info, password verification, feedback and downloads. Not counted: /health, the transfer upload endpoints (which have their own per-minute limits), admin sessions, and — from v3.127.0-beta.0 — image requests carrying a valid gallery token for that gallery.

A verified viewer has a budget of their own (stable from v3.134.5, beta from v3.169.0-beta.0). Requests that carry a valid gallery token for the gallery in their path, to that gallery’s photo list and per-photo feedback (likes, ratings, comments), bulk rating, the lightbox view beacon, my-feedback, feedback-summary, feedback-settings, info, stats and people, count against ten times Max requests per window (3000 at the default), in a bucket separate from the anonymous traffic on the same address. A guest choosing from a large gallery therefore no longer runs out mid-selection, and other people behind the same address do not spend their budget. Downloads, uploads and the password check keep the ordinary budget. The factor is fixed; scale it through Max requests per window. Switching Do not count authenticated requests off turns both the viewer budget and the image exemption off. When a like is refused with 429, the lightbox now shows a message instead of a heart that does nothing.

There are no separate per-image budgets. The per-minute / 5-minute / hour image limits that the Image Security tab used to show were never enforced and are removed.

IPv6 clients

For an IPv4 client the budget key is the address. For an IPv6 client it is the /64 the address sits in, because a single host can use every address in its /64 and would otherwise get a fresh budget per request. IPv4-mapped addresses (::ffff:203.0.113.7) count as the plain IPv4 address. This applies to the general limit, the failed-logins limit and the viewer budget (stable from v3.133.3, beta from v3.135.0-beta.0).

Behind a reverse proxy

The limiter keys on the client IP Express reports. Behind a proxy that is only correct when the proxy forwards X-Forwarded-For and PicPeak trusts that proxy — and trusts nothing beyond it, or a client can choose its own address. TRUST_PROXY sets that boundary:

InstallDefaultWhen to change it
Docker Compose1 (the bundled frontend nginx)Another reverse proxy in front of the frontend (Traefik, Nginx Proxy Manager, Caddy, Cloudflare Tunnel, a host-local TLS proxy): set TRUST_PROXY=2, one per hop. Otherwise every visitor shares the proxy’s single budget.
All-in-one image0 (trust nothing)Behind a reverse proxy pass -e TRUST_PROXY=1, one per proxy hop.
Native process, variable unsetloopback, linklocal, uniquelocal (legacy private-range trust)Set the exact hop count, or false without a proxy. In production the backend logs a warning at startup while it is unset.

A hop count is only safe when clients cannot reach the app by a shorter path. With TRUST_PROXY=1 on Compose, the backend logs a one-time warning when a request reaches the bundled nginx from a private address that already carried a forwarded client address — the sign of an extra proxy that needs 2. See Reverse Proxy → Client IP and Environment variables.

Scanner probes

On the beta channel from v3.135.0-beta.0, paths no PicPeak route uses answer 404 instead of the app’s HTML page: anything ending in .php (also with a version digit, such as .php7), .phtml, .asp, .aspx, .jsp, .jspx or .cgi, and paths starting with the segments /wp-admin, /wp-content, /wp-includes, /phpmyadmin or /cgi-bin. Both the bundled nginx and the all-in-one image’s fallback do this. A 200 told scanners something was there and hid them from log-based tools such as CrowdSec or fail2ban, which count 404s — see Bots and Scanners. Stable still answers these paths with the app page.

Recognising a hit

The backend logs every rejected request as a warning:

Rate limit exceeded ip=… path=/api/gallery/<slug>/thumbnail/123 method=GET
docker compose logs backend | grep -c "Rate limit exceeded"

A non-zero count while a visitor reports missing images is the limiter. On builds before v3.127.0-beta.0 the browser shows nothing: a 429 is a completed request, not a failed one, so it does not appear in the console, and the gallery rendered the missing tile as an empty box.

Changing the limits without the card

On builds before the card existed, the same six settings can be written through the admin API:

curl -X PUT "$PICPEAK_URL/api/admin/settings/security/rate-limit" \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"rate_limit_enabled":true,"rate_limit_window_minutes":15,"rate_limit_max_requests":1000, "rate_limit_auth_max_requests":5,"rate_limit_skip_authenticated":true,"rate_limit_public_endpoints_only":false}'

All six fields are required by that route. On builds before PicPeak/picpeak#1338 a changed window only takes effect after a backend restart (the other values apply within a minute); from that build on the route rebuilds the limiters and everything applies at once. If you would rather edit the database, the keys are the same six names in app_settings, JSON-encoded values.

Where the cache lives

Login middleware caches the security config in process memory for performance. Saving in this tab calls resetSecurityConfigCache() so changes take effect on the next request without a restart.

Last updated on