Skip to content
V
For developers
Architecture, conventions, the core and security
Security / CORS, rate limiting and audit

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#

  1. 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).
  2. Add the limit and the window to config/Shared/ratelimit.neon and pass them through the RateLimitConfig constructor (the services: 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.
  3. Call hit() in the presenter (counts and decides) or check() (only asks). Hash any personal data in the key.
  4. Decide whether the limit obeys the master switch. A brake in front of a write into someone else's account should not.
  5. The throttled response must not reveal more than the passing one.
  6. Log the fact of throttling into the auth log — 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#

  1. Start at cms_system_auth_log filtered by IP or user_id.
  2. Compare login_fail against login_success in the same window — password guessing looks like a long series of failures from one address.
  3. Take refresh_reuse_detected seriously. A reused refresh token means somebody else has it.
  4. rate_limited says the brake worked — but not whether it was strict enough.
  5. The Tracy log (auth) is in log/ too — it holds what was not allowed into the response: throttled password resets, audit write failures.
  6. 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:

  1. the NETTE_DEBUG environment variable — 1, true, on, yes turn it on; anything else including 0 and false turns it off,
  2. the existence of the config/debug-mode.flag file (not in the repo, created with touch on the dev server),
  3. 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.phpresolveDebugMode()

Follow-up chapters: Authentication and authorisation · RBAC in the API · Scheduled tasks · Deployment checklist