Skip to content
V
For developers
Architecture, conventions, the core and security
Security / Authentication and authorisation

Authentication#

Two independent routes.

Route Where With what
Session website and administration the sign-in form
JWT API a token in the header; external calls always also carry the application key

Sessions#

Sign-in is by username or e-mail. The account is looked up by App\Core\Security\LoginUser::findUserByIdentifier(): a value shaped like an e-mail is matched first against email and only without a match against username.

The lookup order is a security matter

A username may contain @, and so another person's e-mail. If usernames were searched first, an attacker registering the username victim@example.com would capture the victim's attempts to sign in with their e-mail. The password is verified against just the one account found.

Inactivity Signed out after
website without "remember me" 30 min
website with "remember me" 31 days
administration always 30 min

Inactivity is measured by the lastActivity stamp in the session's auth section (AuthSessionStorage::IDLE_TIMEOUT_SECONDS, REMEMBER_TIMEOUT_SECONDS). When exceeded, the tokens are revoked as on sign-out.

JWT for the API#

The token is obtained from the sign-in endpoint and sent in a header:

Authorization: Bearer <token>
Item Note
Lifetime limited, refreshed by a refresh token
Rotation the refresh token changes on use
Reuse detection using an old refresh token invalidates the chain
Deny list a signed-out token stops working immediately
User revocation stamp after blocking, deactivation, a password change or reset and an account deletion request, no previously issued access token is valid

Rotation with reuse detection

A stolen refresh token is recognised by being used twice. The system then invalidates the whole branch — the attacker and the legitimate user both have to sign in again.

The revocation stamp#

Besides the refresh tokens, UserRefreshTokenManager::revokeAllForUser() writes a stamp jwt:user-revoked:<id> = time (TTL = jwt.ttl) into the deny list. AccessTokenValidator::isRevoked() then rejects every token whose iat is older than the stamp — in the Api guard, in checkAuthentication() of the website and the administration, and in is-auth-valid. A token issued in the same second stays valid, so a session that changed its own password gets a new pair and stays signed in.

Cost and failure

One Redis GET per request with a token (the same as the jti deny list), without Redis one primary-key SELECT in cms_system_jwt_denylist. Fail-open like the whole deny list: a store outage does not enforce the stamp.

What the token carries and what follows from it#

Whether a token is usable is decided by a single service, AccessTokenValidator: signature and expiry (JwtManager) plus the jti deny list and the revocation stamp. The Api guard, checkAuthentication() of the website and the administration, and LoginUser::isAuthenticated() (is-auth-valid) all ask it.

Claim in data Meaning
userId the identity; 0 means no identity (the type is int, not ?int)
role only an informative list of roles at sign-in time
caps capabilities — proof of access to one record
  1. Permissions are not read from the token. The resolver reads the caller's roles from the database on every request, so removing a role applies at once, even though a refresh family lives up to 60 days.
  2. userId = 0 is a guest. PermissionResolver turns 0 and negative ids into null, i.e. the anonymous branch. Code reading getUserId() outside the resolver must handle zero itself.
  3. A token without jti counts as not revoked. Rejecting it would sign out every live session holding a token issued earlier at deploy time.
  4. Reading is tolerant. A missing key gets its default and a broken capability is dropped; the rest of the token stays valid.

Checking authentication with isExpired() alone keeps a revoked token signed in

JwtManager::isExpired() sees only the expiry. A signed-out or stamp-revoked token that has not expired yet would keep the session alive. Any new place that verifies a token calls AccessTokenValidator.

Cleaning the token and limit tables#

Three tables grow with sign-ins, token refreshes and sign-outs and never clean themselves. Daily crons do it:

Table Cron What it deletes Why not earlier
cms_system_user_refresh_tokens /cron/system/user-refresh-token/purge-old (?graceDays=N) tokens past expiry (plus N days of margin, 0 by default) rotation only revokes the old token; until expiry it must stay for reuse detection and the parent_id chain
cms_system_jwt_denylist /cron/system/jwt-deny-list/cleanup-expired expired entries —
cms_system_rate_limit /cron/system/rate-limit/purge-old (?age=N in seconds) rows older than twice the longest window (sign-in / refresh) an expired window resets on the next attempt, but a key nobody touches again would stay forever

The deny list and the limits use the table only with jwt.useRedis: false or ratelimit.useRedis: false; with Redis the keys expire by themselves and both crons return skipped: redis-backed.

Deleting a refresh token before expiry turns off theft detection

A manual "clean-up" of rotated tokens (with revoked_at set) before they expire means a stolen old token is no longer recognised as reused — and the whole branch is not invalidated when it is used.

Passwords and secret inputs only in the body#

Nette takes the parameters of an action*() signature only from the URL — and a URL ends up in the server access log, in proxies and in the client's history. Actions with passwords, keys and tokens therefore have no such parameters in the signature and read them through BasePresenter::readBodyParameter() (Api), which takes a form body and JSON alike:

Action From the body From the URL
:Api:System:User:updatePassword password, currentPassword nothing
:Api:System:User:resetPassword email, key, password nothing
:Api:System:Auth:isAuthValid token (the Authorization header takes precedence) nothing
:Api:System:User:activate email, activationKey preferably kept because of the e-mail link

A new secret input does not belong in the action signature

A parameter in an action*() signature means the URL. Read it with readBodyParameter() and ignore the value in the URL — not as a fallback.

Password rules#

App\Core\Security\PasswordPolicy:

Password Minimum length
user (registration, change, reset) 8 (USER_MIN_LENGTH)
user created or changed in the administration 10 (ADMIN_MIN_LENGTH)
advert editing password 6 (ADVERT_MIN_LENGTH)

Changing one's own password (UserManager::updateOwnPassword()) verifies the current password against the DB and counts failures into the per-account sign-in limit. After the change a pending password reset is cancelled, all refresh tokens are revoked (reason password_change) and the revocation stamp is written. A password change by an administrator does the same. A forgotten password sends a one-time link (PasswordResetToken, valid for 1 h, only the SHA-256 in the DB); the password itself does not change.

The Api checks the minimum length only where the visitor chooses the password

The Api enforces USER_MIN_LENGTH on a change of one's own password and on a reset from the e-mailed link. The system rewrite UserManager::updatePasswordForUser() (today the lazy rehash on sign-in) does not check the length: an old five-character password must keep working, otherwise its owner could not sign in to change it. Do not add the check there.

Password and e-mails when saving an account from the admin#

The password is not an entity field the form writes. The admin user form (UserFormTrait) sends it in the $meta of an ordinary save() and the Api UserManager handles it in its hooks:

$meta key When the form sends it What the Api does
password only a filled password field beforeSave() hashes the password into the entity; on an update afterSave() then revokes sessions and a pending password reset
skipNotifications when creating an account afterSave() does not send the registration and activation e-mail

On create the password is required, on update an empty field means "no change". Both branches are guarded by PasswordPolicy::ADMIN_MIN_LENGTH.

Procedure: saving a user from your own code

  1. Put the password into $meta['password'], not into setPassword().
  2. When you create an account on someone's behalf (import, admin), add $meta['skipNotifications'] = true.
  3. Verify signing in with the new password, not just isSuccess() of the save.

A password set on the entity is silently dropped

The password property has FieldAccess with both insert and update set to Audience::None, so the save payload never carries it. The save succeeds and the account keeps its old password (an empty one for a new account).

Lost password and account activation#

Both paths are opened by a signed-out person from a link in an e-mail, so they rest on a one-time secret in the link, not on authentication. The DB holds only its SHA-256 with an expiry; the readable form exists only in the e-mail.

Action (#[PublicAction]) UserManager method Response
:Api:System:User:sendNewPassword (username = name or e-mail) sendNewPassword() always success with code OK
:Api:System:User:sendActivationEmail and :Api:System:Auth:sendActivationEmail (username) sendActivationEmail() always success with code OK
:Api:System:User:resetPassword (email, key, password from the body) resetPassword() OK; otherwise USER_PASSWORD_RESET_KEY_INVALID or INVALID_LENGTH
:Api:System:User:activate (email, activationKey) activateUser() OK, USER_ALREADY_ACTIVE or USER_ACTIVATION_KEY_INVALID
:Api:System:User:generateNewPassword (admin only, not public) generateNewPassword() new password in data.password, no e-mail
Link Validity (TTL_SECONDS) Target
password reset (PasswordResetToken) 1 h the "Lost password" page (Page:form, component lostPasswordForm) with email and key
activation (ActivationKey) 72 h :Front:System:Registration:registration with email and activationKey

Rules both paths keep:

  1. The response does not reveal whether the account exists. sendNewPassword() returns the byte-identical Result::success(OK) for an unknown account, an existing account and an internal failure (DB, e-mail); the failure goes only to the auth log. An exhausted rate limit of the action gives the same response. resetPassword() and activateUser() have a single code for an unknown e-mail and a wrong, expired or used key.
  2. A reset request does not change the password. Only a pending token is created; the old password stays valid until the owner uses the link. A stranger therefore cannot lock the account. A new request overwrites the token and only the last sent link is valid; the same for activation (every send issues a new key).
  3. A short password does not consume the token. INVALID_LENGTH (PasswordPolicy::USER_MIN_LENGTH) comes only after the token check, so the length is not an oracle, and the link stays valid.
  4. Success consumes the key. The reset writes the password, clears the token and revokes all refresh tokens of the account; activation sets activated and clears the key. An already active account gets USER_ALREADY_ACTIVE, not a key error. The code comes before the key is verified, though, so the front end does not distinguish it: the activation page (RegistrationPresenter::activateUser()) shows a flash and redirects on success, and one generic error directly in the page on anything else.
  5. An unsent e-mail restores the previous key. When the e-mail cannot be queued, UserManager conditionally restores the original digest and expiry (only while the row still carries the digest issued now), so the link already in the user's mailbox keeps working.
  6. The key state is read from the DB outside the query cache (find($id, [], false)). From a stale copy a used key could be redeemed a second time.

The front must not say \"account does not exist\"

The sendNewPassword response is constant on purpose. A messages.noUser message or any other branch by the result would turn the form into a tool for finding registered e-mails.

Telling \"already active\" apart on the front end is an oracle

A message "the account is already active" would reveal, on the activation address with a foreign e-mail and any key, that the account exists and is active.

Do not make deliverPasswordResetLink() public

The method is private so that the only entry is sendNewPassword() with its constant response. A public call would return the success × failure difference, i.e. an account oracle. The token must also never go into data or message of any response — whoever gets it takes the account.

generateNewPassword is not the lost-password flow

It is a regeneration by an administrator: it returns the readable password to the caller to hand over through another channel and revokes all sessions of the account and a pending password reset. The key must stay with the admin role only; a guest grant would let anyone overwrite the password of any account and read it in the response.

Application key#

Every external Api call sends an X-API-Key header. The key says one thing only: this application may call the Api. It does not replace signing in and does not decide which data the application gets — it goes together with the optional user token:

Request Who the caller is
key, no token guest — role guest, the same as an anonymous website visitor
key + valid token the user from the token with their roles
key + invalid, expired or revoked token 401 USER_NOT_LOGGED_IN (103) — it never silently falls back to guest
no key 401 API_UNAUTHORIZED (501), nothing else is evaluated

A client may only narrow a user's rights — with its allowed roles, see RBAC → The application role ceiling. Scopes (scopes) and the #[RequiresScope] attribute are gone; no action needs marking because of the key.

Code: app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/.

Shape and storage#

Thing Value
Shape coolcms_ + 64 hex characters (32 random bytes), ApiClientKey::generate()
In the DB cms_system_api_client_keys — an application has several keys (at most 5 valid): key_hash = SHA-256 of the whole key, key_prefix = the first 16 characters to tell keys apart in the administration, an optional label, expires_at (NULL = no expiry), revoked_at, the key's own last_used_at
Validity a key works when it is not revoked and has no expiry or the expiry has not passed yet; the application must also be enabled and not revoked. The expiry is compared with the request time, not with the time of the cached record. Order: unknown → key revoked → expired → application revoked → disabled
Readable key only in the response to creating the application and to issuing another key (data.key), nowhere afterwards
Allowed roles junction table cms_system_api_client_roles
Record cache Nette cache keyed by the hash, api.clientKey.cacheTtl (at most 60 s); every change of the application or a key through ApiClientManager clears the records of all the application's keys, so disabling, revoking and a new expiry apply at once. An unknown key is not cached

The administrator manages clients in Settings → Applications with Api access (:Admin:System:ApiClient), through the Api endpoint api/system/api-client — all its actions are admin only.

One-time key display in the administration#

The administration saves with PRG, so the readable key from the response to creating an application or issuing a key has to survive the redirect. It must not go into the address (history, Referer, access log) or into a flash message (which is translated as a key, and a missing translation is logged). ApiClientKeyHandover hands it over through the session:

Step What happens
POST (add, addKey) store() stores the key with the reason created / issued and the application id; the redirect always goes to the page that shows it (application list / keys of that application), never to the Referer
GET of the target page take() returns only the record with its own reason and application and deletes it at once; a foreign record stays
a record never taken expires after EXPIRATION (10 minutes)
an exception during the request ApiClientPresenter::startup() adds SESSION_KEY and key to Tracy keysToHide, so the key does not end up in log/exception-*.html

Never log the key from a create / issue-key response

ApiClientRepository does not write the result, not even on error. A log line or a dump with data.key means anyone with access to the logs can use the key, and the only remedy is to revoke it.

Application update replaces all four limits

An omitted limit falls back to the default. ApiClientFormPayload therefore always sends all four and always roleIds; omitted roleIds, on the other hand, would leave the roles unchanged.

Refusing the key#

A missing, unknown, disabled, revoked or expired key gets one and the same response, so the caller cannot tell which key exists:

{"success":false,"message":"A valid application key (X-API-Key) is required.","data":null,"code":501}

with HTTP 401. The reason goes only to the log:

Where What
log/auth.log always a line API_CLIENT_KEY reason=… client=… key=… presenter=… action=… ip=…; reason is missing, unknown, disabled, revoked or expired, client the client id and key the key id (not the key), otherwise -
cms_system_auth_log event api_key_failure with meta.reason (and meta.keyId) — only for an unknown, disabled, revoked or expired key

A missing key does not go to the table: every robot that stumbles on /api/ sends one, and the auth log would turn into an access log. Neither the key nor any part of it is written anywhere.

Application limits#

Limits belong to the application (keyed by the client id), not to a single key — all keys of the application share them.

ApiClientRateLimiter, fixed windows in Redis (ratelimit:apiclient:<id>:<window>:<window number>, INCR + EXPIRE, all of a client's windows in one pipeline). Without Redis (ratelimit.useRedis: false) the windows go through DbRateLimit.

Window Identity Default (api.clientKey.limits) When it is counted
client_second the whole application perSecond: 20 checkClientKey(), right after the key is verified
client_minute the whole application perMinute: 600 same place
user_minute userId from the JWT payload userPerMinute: 120 checkClientCallerLimit(), after JWT
ip_minute SHA-256 of the address ipPerMinute: 120 same place, when the payload has no userId

A client keeps its own values in the columns limit_per_second, limit_per_minute, user_limit_per_minute, ip_limit_per_minute; NULL = the configured default.

On #[PublicAction] the count is by address even with a token

checkJwtAuth() skips a public action, the payload stays null and the per-caller limit falls to ip_minute.

Exceeding a limit returns HTTP 429, a Retry-After header (seconds until the window ends) and:

{"success":false,"message":"Too many requests.","data":{"retryAfter":3},"code":404}

Only the first refusal in a window goes to cms_system_auth_log: event rate_limited, meta.reason = api_client_limit, meta.window = the window name, meta.limit. A Redis outage is fail-open (a RATELIMIT_ERROR api_client redis … line in auth.log).

The sign-in and token refresh limit is a different gate

AuthPresenter counts its limits inside the operation (internalActionLogin(), internalActionRefresh()) so that the external and internal paths share them. The external action then sends 429 + Retry-After with the body Too many requests, retry in <N>s and data.retryAfter; an internal caller only gets a Result with RATE_LIMIT_EXCEEDED and reads data.retryAfter. The auth log event is also rate_limited, with meta.type = login or refresh.

Exemptions from the key#

An action called by a third party that cannot send our key carries #[NoClientKey] (app/Core/Attributes/NoClientKey.php). There are four today, all of them also #[PublicAction]:

Action Caller
:Api:System:Payment:execute the payment gateway (notifyUrl) and the customer's return
:Api:System:Payment:delivery gateway notifications, the SMS aggregator's delivery report
:Api:System:Payment:expressExecute SMS aggregator — an incoming express payment
:Api:System:Auth:oauthDeauthorizeCallback the OAuth provider

The exemption drops only the key, not authorization

The action still passes the JWT guard and RBAC (usually as a guest). Its security rests on its own verification — the gateway signature or a payment check at the provider. Adding #[NoClientKey] to an action without its own verification opens it to anyone.

A test holds the list

tests/Unit/Security/ApiClientKeyExemptionTest.php compares the attributes in the code with its list. A new exemption = an entry in the test with a reason. The attribute is read from the action*() method; an unknown action is not exempt (fail-closed).

The internal path#

The website and administration call the Api through ApiBridge::internalCall(), which never runs startup(). The key, the limits and the role ceiling do not concern them; their API_RBAC … path=internal line has client=web.

The api.clientKey.required switch#

config/Shared/api.neon, default true. With false a call without a key passes as before (no token = 401 on a non-public action); a call with a key is still verified, limited and capped by the role ceiling — otherwise a bad key would "pass" as no key. A compile-time parameter; clear temp/cache after changing it.

The key does not belong in a URL

URLs are logged and stay in history. The key belongs in a header, over HTTPS only.

Sign-in through an outside service#

app/UI/Front/System/Components/LoginExternalComponent/ and the providers in the administration. The front component verifies the identity with the provider through Hybridauth and passes the result to the internal operation AuthPresenter::internalActionLoginExternal() — it has no HTTP action. A bare "provider, id and e-mail" from outside would be enough to take over someone else's account.

Situation Result
the e-mail of an existing account, not verified by the provider nothing is written, USER_EXTERNAL_EMAIL_NOT_VERIFIED (113)
a new account with an unverified e-mail created inactive + an activation e-mail, USER_ACTIVATION_REQUIRED (114)
a verified e-mail of an account registered with a password and without a stored identity not signed in; a link to confirm the linking goes to the account's e-mail, USER_EXTERNAL_LINK_CONFIRMATION_SENT (115)
the link is invalid, used, expired, or the identity meanwhile belongs to another account USER_EXTERNAL_LINK_KEY_INVALID (116)
an account without a password (created through the service) or with an already stored identity linked and signed in at once

The linking confirmation is handled by App\Core\Security\ExternalLinkConfirmation: the token only as SHA-256 in the DB, valid for 24 h, one request per account, at most one e-mail per 15 min (cms_system_user_external_link_requests, template SY_EXTERNAL_LINK_CONFIRM). Whether the provider verified the e-mail is evaluated by ExternalEmailVerification.

A provider's secret is not displayed in the administration

The field is a password field; empty means "keep". Do not display it, not even in a log.

The front component takes the provider keys (clientId, clientSecret, scope) from the internal action :Api:System:LoginProvider:oauthConfig (LoginProviderManager::oauthConfig($key)), not from the entity: the fields carry #[FieldAccess(read: Audience::Privileged)] and the front end runs as a guest, so it would get empty strings from the entity. The action has no action*() and builds its payload by hand; an unknown or inactive provider returns [] and the component ends with BadRequestException.

An advert placed without an account (Bazaar, Comcat) is edited by a visitor who entered its password. No identity reaches the Api layer otherwise — the guard reads only the JWT — so the front passes that fact as a capability in an internal token: JwtCapability = proof of access to one record, neither a role nor an identity.

Field (caps[]) Purpose
scope the domain: bazaar.advert.edit vs comcat.advert.edit; without it advert 5 would unlock company 5
id the single record, never a blanket grant
exp expiry, a copy of the editing session expiry (advertSessionExpiration, 7200 s)
fp sha256 of the password hash in the DB at issue time
request → grantAdvertEditCapabilityFromSession()  → token with caps[] from the session → ApiBridge
        → checkAutorization()  → session [id, hash, exp] × hash from the DB → issues the token again
        → internalAction*()     → isOwner() compares caps[].fp with the password fingerprint from the DB
  1. CapabilityTokenIssuer::grantAdvertEdit() issues it from EditAdvertFormTrait (front Bazaar and Comcat), again on every request: first from the session before the advert is loaded (otherwise get for editing would return a masked e-mail and the form would fail validation), then again after the session is checked against the hash in the DB.
  2. The Api verifies it — ownsByCapability() in the Api AdvertPresenter and AdvertScopeGuardTrait (the responses and statistics tabs) through JwtCapability::grants(): scope, id, expiry and the password fingerprint of the entity loaded from the DB, compared with hash_equals(). An empty hash or fingerprint never passes.
  3. The identity does not change. The token takes userId, role and username from the front session; without one it stays userId = 0, a guest. A signed-in user in someone else's advert therefore keeps their own owner grants.
  4. A capability holder sees personal data only of the rows of that one advert (exemptPersonalDataForCapabilityHolder(), see RBAC).

Never put the owner into a capability

Putting the advert owner's insertUserId into the token would give an anonymous editor all rights of that account, not one advert.

The capability is never stored

The token lives only in the bridge memory for one request — not in the session, not in a cookie, not in the browser. A password change by the owner is enforced by two independent checks against the DB (the front session and isOwner() in the Api); a stored capability would survive the password change until it expired.

Account deletion and the sign-in gates#

A user deletes their account themselves; after a grace period (account.deletion.graceDays, default 7 days) a cron anonymizes it. The row in cms_system_users stays because of invoices and posts.

Account state By password Via a service By token refresh User:save
pending deletion (isDeletionPending()) USER_DELETION_PENDING (117) + scheduledAt and a short-lived ticket for cancelling refused USER_NOT_FOUND —
anonymized (isDeleted()) unknown account (110) no longer leads to the account — the identity is released the refresh tokens are deleted USER_DELETED (120)

The last gate comes only after the tokens are issued. AuthPresenter::refuseTokensOfChangedAccount() (called by password sign-in, sign-in via a service and token refresh) reads the account state once more straight from the DB (UserManager::isSignInAllowedNow(), one scalar query outside the query cache). When the account was banned, deactivated or asked for deletion meanwhile, it revokes the just-issued refresh token, puts the access token's jti on the deny-list and hands out nothing. Why after the issuance: a deletion request first writes the pending state and only then revokes the sessions in bulk. A refresh token inserted before that write is caught by the bulk revocation, one inserted after it is seen by this check.

A new path to a token must go through refuseTokensOfChangedAccount()

Checking the state only before issuance is not enough: a deletion request or a ban landing between the check and the refresh token write would leave a valid session to a closed account.

The ticket is an HMAC over the id, the request time, the random version of the request (deletion_nonce) and a 30-minute expiry (AccountDeletionTicket); the website keeps it in the session and it never reaches the HTML. Cancelling, a new request, the cron claiming the anonymization and the anonymization itself replace or clear the version, so every older ticket stops matching.

The request time is not a version

deletion_requested_at has second precision. A cancel and a new request within the same second would give the same value and the old ticket would cancel the new request. Only deletion_nonce binds the ticket to one request. The e-mailed cancel token is not reused on purpose: its stored digest would be overwritten by a new issue and the link in the e-mail would stop working.

The request revokes all tokens of the account. The request, confirmation, cancellation and anonymization flow is driven by AccountDeletionManager; how a module joins the anonymization is described in Modules. Every state change is written to the accountAudit channel (only the id and the way, never the e-mail).

What the API requires#

Verification runs as the very first thing in every Api presenter, in this order:

Step The question What happens when it fails
applyCors() is this browser allowed? the preflight request is answered right away (204)
checkClientKey() may this application call the Api? 401 (code 501); application limit 429
checkJwtAuth() who are you? invalid token 401 (code 103); no token with a key = guest
checkClientCallerLimit() has one caller used up the application? 429
checkApiPermission() what may you do? externally 403; on the internal path (bridge) an empty result

The order lives in app/Core/Base/Api/BasePresenter.php::startup() and is binding. The internal path (ApiBridge) does not run startup(), so of this table only the permission check applies there — through its own guard.

Exceptions are marked in the code itself with the #[PublicAction] attribute — sign-in, token refresh, the activation email, forgotten password, and the return addresses of payment gateways.

A missing permission on the internal path is not an error — it is SILENTLY EMPTY

This is today's most commonly hit trap. A call through ApiBridge with no privilege record returns neither a 403 nor an error into the page; it returns an empty list. When something "does not work and there is no error anywhere", look for the privilege first, not for a bug in the code.

A public action needs a brake of its own

Whatever sits behind #[PublicAction] is not protected by a token. Sign-in, token refresh and forgotten password therefore have rate limits; a new public action must get one too, or it can be called without limit.

Auditing#

Sign-in attempts are written to log/auth.log and to a table. Outwardly an unknown account and a wrong password share one code (110); the difference is only in the log (meta.reason). An unknown account is verified against a dummy hash so the response is not measurably faster.

The audit log is never rewritten

Not even when cleaning up test data. It is a record of what actually happened; rewriting it would make it a forgery.

Switches#

config/Shared/security.neon.

A disabled protection reminds you of nothing

Turn a check off for debugging and production will not flag it. Put it back at once, not "later".

Cookie settings