CORS, rate limiting and audit#
Three protections of the Api layer that are unrelated to each other and each solves
something different. Plus what is public even though it should not be. The switches:
config/Shared/security.neon.
| Protection | What it protects against | Where the config is |
|---|---|---|
| CORS | a foreign site calling the API from a signed-in user's browser | config/Shared/cors.neon |
| Rate limiting | password guessing, mail bombing and bulk draining of an endpoint | config/Shared/ratelimit.neon |
| Audit log | having nothing to reconstruct an incident from | the cms_system_auth_log table |
The master switches#
security:
cors: true # CORS headers + preflight 204
rateLimit: true # brute-force protection on /auth/login and /auth/refresh
auditLog: true # writing into cms_system_auth_log
Each switch disables only its own protection; the detailed configuration stays in
its own file. The JWT guard is not here — it is driven by jwt.guardMode (log = logs
only, enforce = 401), currently enforce.
Not every limit obeys the master switch — and that is deliberate
showPhone and sendNewPassword do not follow security.rateLimit. They are
the last brakes before a write into someone else's account, and turning them off
with a single switch together with login brute-force protection would mean turning
off two very differently serious things at once. Anyone wondering "why am I being
throttled when rate limiting is off" is looking for this.
CORS#
CORS is the server's answer to the browser's question "may this foreign site read what
you return?". It is applied by CorsTrait::applyCors() in the Api presenter.
| Step | Behaviour |
|---|---|
a request with no Origin header and not OPTIONS |
same-origin → CORS is not involved at all |
| an allowed origin | Access-Control-Allow-Origin + Vary: Origin (+ Allow-Credentials) |
preflight (OPTIONS) |
returns 204 and ends the request, whether the origin is allowed or not |
| a disallowed origin | the headers are not sent, the browser discards the response itself |
applyCors() must run BEFORE the JWT check
A preflight OPTIONS sends no Authorization. If the JWT guard saw it first, it
would return 401 — and the browser would never send the actual request. It surfaces
as "it does not work from JavaScript but works from curl".
A wildcard in allowedOrigins is useless in production, not just unsafe
With allowCredentials: true the browser ignores
Access-Control-Allow-Origin: *. The code handles it (the wildcard is only used
without credentials), but the consequence is that a wildcard in the dev config looks
like a working setting that stops applying in production with authentication. List
concrete origins.
CORS protects the browser, not the server
A request from curl, from a server or from a mobile app is not restricted by CORS at all. It is not authorisation and must not be mistaken for it — that is the JWT guard and RBAC.
Rate limiting#
Hits are counted per key in a sliding window. The configured limits
(config/Shared/ratelimit.neon):
| What | Key | Limit | Window |
|---|---|---|---|
/auth/login |
(user, IP), the name hashed |
5 | 5 min |
/auth/refresh |
IP | 30 | 5 min |
| revealing a phone number | IP | 20 | 1 hour |
| forgotten password — IP | IP | 10 | 1 hour |
| forgotten password — account | a hash of the username | 3 | 1 hour |
Forgotten password is the only one with two dimensions, and both must pass: a per-IP limit does not stop a botnet aimed at one account, a per-account limit does not stop one IP firing at thousands of names.
The username goes into the key hashed
For two reasons: it is personal data (often an email) and has no business sitting
in a rate-limit table, and the hash also unifies Franta and franta into one key
— otherwise the limit could be dodged by changing letter case.
Rate limiting is fail-open
A Redis or database outage means allowed = true. That is intentional — signing in
must not break because of a counter — but it means an outage of the store releases
the brake, and nothing surfaces as an error.
For login, only FAILED attempts are counted
If successes counted too, a user signing in five times in five minutes would close their own window. The consequence: the limit guards against password guessing, not against load.
A per-IP limit hits a whole company at once
Users behind one public address share the quota. For integrations, key by identity rather than by address.
Procedure: adding a rate limit to a new action#
- Decide the dimension. What is the thing you are protecting — an account (key by user), a source (by IP), or both (then both must pass).
- Add the limit and the window to
config/Shared/ratelimit.neonand pass them through theRateLimitConfigconstructor (theservices:block at the end of the file). Write a comment next to each value saying why that number — nobody will raise or lower an unexplained number later. - Call
hit()in the presenter (counts and decides) orcheck()(only asks). Hash any personal data in the key. - Decide whether the limit obeys the master switch. A brake in front of a write into someone else's account should not.
- The throttled response must not reveal more than the passing one.
- Log the fact of throttling into the
authlog — that is where it belongs, because it must not be in the response.
A different response when throttled is an oracle for account existence
For "forgotten password" the code returns a constant success even when throttled
— precisely so an attacker cannot tell which attempt they are on, and above all so
that a per-account limit does not distinguish an existing account from a
non-existent one. The SendNewPasswordRateLimitTest test guards that the two shapes
match. Adding "the limit was exceeded" to the response removes this protection
without anything breaking.
The audit log#
The cms_system_auth_log table, written by AuthLogManager::log(). The event types:
| Group | Events |
|---|---|
| sign-in | login_success, login_fail, login_external_success, logout |
| tokens | refresh_success, refresh_fail, refresh_reuse_detected, jwt_failure |
| access | api_key_failure, scope_denied, rate_limited |
meta carries free-form JSON context. Retention is handled by the
/cron/system/auth-log/purge-old cron — 90 days by default, overridable with ?days=N.
A raw token or password must NEVER go into meta
Only a hash or the first few characters. An audit log is the last place you want a usable credential to land — it survives there for months and is read by more people than the production database.
Writing to the log is fail-open
A database error is logged through Tracy and execution continues — the audit must not block a sign-in. That does mean, however, that a missing audit row does not prove the event did not happen.
Without the cron the table grows forever
Every failed sign-in attempt is a row. On a site that bots aim at, that is thousands of rows a day — and nobody notices until the administration starts being slow.
An audit log is never rewritten
Not even while cleaning up test data. Deleting by age through the cron, yes; editing the content by hand, no — a rewritten log is a forgery and not worth keeping.
Procedure: investigating suspicious activity#
- Start at
cms_system_auth_logfiltered by IP oruser_id. - Compare
login_failagainstlogin_successin the same window — password guessing looks like a long series of failures from one address. - Take
refresh_reuse_detectedseriously. A reused refresh token means somebody else has it. rate_limitedsays the brake worked — but not whether it was strict enough.- The Tracy log (
auth) is inlog/too — it holds what was not allowed into the response: throttled password resets, audit write failures. - Touch the limit configuration last. First you have to know what happened.
CSRF on signals#
Every handle*() gets a same-origin guard automatically
Nette (AccessPolicy::applyInternalRules(), version 3.3) adds
Requires(sameOrigin: true) to every signal method unless it is marked
#[CrossOrigin] or Requires(sameOrigin: false). A foreign origin ends as a
detected CSRF.
Same-origin is recognised from the Sec-Fetch-Site header, which a non-browser client does not send
Without it Nette falls back to a check via a strict cookie. A curl client or a server-to-server call therefore will not get through a signal — which is correct as CSRF protection, but it is also why a signal cannot be used as an API endpoint.
Turning the guard off with #[CrossOrigin] means writing the authorisation yourself
A signal without the same-origin check can be triggered from any foreign page in a signed-in user's browser. That holds for actions that look harmless too — "just a like" or "just a subscription" is still a write in someone else's name.
What is public even though it should not be#
The rewrite rules live in .htaccess at two levels: the root file rewrites
everything to /www/, except the whitelisted docs/ folder. And www/.htaccess
hands existing files straight to Apache as static content.
| Path | Status |
|---|---|
docs/ |
served statically, outside the application and outside its authorisation |
docs/sql/ |
denied by its own .htaccess (Require all denied) |
www/private/ |
denied by its own .htaccess — invoice PDFs and QR codes |
www/uploads/, www/modules/** |
public, downloadable without signing in |
An existing file is served by Apache with NO check whatsoever
RewriteCond %{REQUEST_FILENAME} !-f only holds for non-existent files — on an
existing .pdf the rewrite block is not applied at all. That is exactly how the
invoices in www/private/pdf/… were downloadable by anyone who guessed the path:
the application never learned about the request, so there was nowhere to log it.
Fixed by the www/private/.htaccess file; downloads go exclusively through a PHP
proxy that sends the file with readfile() only after verifying ownership.
docs/ is public — do not generate anything with data into it
It held a complete SQL workbook with the database schema and returned HTTP 200.
Outputs containing data belong in var/.
.htaccess is a defensive layer, not a solution
It works only where Apache allows AllowOverride for that folder. The reliable fix
is not to serve the sensitive path from the server's vhost at all.
Debug mode#
Debug is switched on explicitly; the production default is off. It is decided in
App\Bootstrap::resolveDebugMode() in this order:
- the
NETTE_DEBUGenvironment variable —1,true,on,yesturn it on; anything else including0andfalseturns it off, - the existence of the
config/debug-mode.flagfile (not in the repo, created withtouchon the dev server), - otherwise off.
Tracy switched on prints the configuration including secrets
Payment gateway keys and service credentials end up in the page source or on a bluescreen. Anyone who turns debug on in production "just for a moment" exposes them to everyone who triggers an error in that moment.
Where to look#
| I want | Where |
|---|---|
| to switch a protection on or off | config/Shared/security.neon |
| the allowed origins | config/Shared/cors.neon |
| the limits and windows | config/Shared/ratelimit.neon |
| where the CORS headers are applied | app/Core/Traits/Api/Presenters/CorsTrait.php |
| the rate limit backends | app/Core/Security/RateLimit/ |
| the audit write | app/UI/Api/System/Models/Managers/AuthLogManager.php |
| the audit and limit cleanup | app/UI/Cron/System/Presenters/{AuthLog,RateLimit}Presenter.php |
| how debug mode is decided | app/Bootstrap.php → resolveDebugMode() |
Follow-up chapters: Authentication and authorisation · RBAC in the API · Scheduled tasks · Deployment checklist