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:
| 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 |
- 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.
userId = 0is a guest.PermissionResolverturns0and negative ids intonull, i.e. the anonymous branch. Code readinggetUserId()outside the resolver must handle zero itself.- A token without
jticounts as not revoked. Rejecting it would sign out every live session holding a token issued earlier at deploy time. - 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
- Put the password into
$meta['password'], not intosetPassword(). - When you create an account on someone's behalf (import, admin), add
$meta['skipNotifications'] = true. - 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:
- The response does not reveal whether the account exists.
sendNewPassword()returns the byte-identicalResult::success(OK)for an unknown account, an existing account and an internal failure (DB, e-mail); the failure goes only to theauthlog. An exhausted rate limit of the action gives the same response.resetPassword()andactivateUser()have a single code for an unknown e-mail and a wrong, expired or used key. - 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).
- 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. - Success consumes the key. The reset writes the password, clears the token and revokes all
refresh tokens of the account; activation sets
activatedand clears the key. An already active account getsUSER_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. - An unsent e-mail restores the previous key. When the e-mail cannot be queued,
UserManagerconditionally 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. - 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:
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.
Advert password: a capability in the token#
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
CapabilityTokenIssuer::grantAdvertEdit()issues it fromEditAdvertFormTrait(front Bazaar and Comcat), again on every request: first from the session before the advert is loaded (otherwisegetfor 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.- The Api verifies it —
ownsByCapability()in the ApiAdvertPresenterandAdvertScopeGuardTrait(the responses and statistics tabs) throughJwtCapability::grants(): scope, id, expiry and the password fingerprint of the entity loaded from the DB, compared withhash_equals(). An empty hash or fingerprint never passes. - The identity does not change. The token takes
userId,roleandusernamefrom the front session; without one it staysuserId = 0, a guest. A signed-in user in someone else's advert therefore keeps their ownownergrants. - 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".