Skip to content

Login Security

Claude edited this page Oct 23, 2026 · 9 revisions

Login Security

oCMS includes built-in protection against brute force attacks and DDoS on the login form.

Login Destinations

Successful login and visits to /login with an existing session use the same role-based destinations:

Role Destination Access
Administrator (admin) /admin Full administration
Editor (editor) /admin Content management; administrator-only routes remain forbidden
Public (public) /account Own account overview; no administration access
Unrecognized role / No account dashboard or administration access

Destinations are fixed internal paths. Login query parameters and form fields cannot override them. The Account Dashboard shares the admin layout with account-only navigation, English/Russian language selection and POST logout.

Account access reloads the user and role from the database. Demoting an editor to public removes administration access immediately and changes the login destination to /account. Deleted users and sessions invalidated by password changes must sign in again. See Security for session invalidation details.

Overview

The login protection system provides two layers of defense:

  1. IP-based Rate Limiting — Limits the number of login attempts per IP address
  2. Account Lockout — Temporarily locks accounts after multiple failed login attempts

IP-based Rate Limiting

Rate limiting prevents rapid-fire login attempts from a single IP address.

Configuration

Setting Value Description
Rate 0.5 req/s One request per 2 seconds (sustained)
Burst 5 Initial burst allowance before rate limiting kicks in

How It Works

  1. Each IP address has a token bucket with 5 tokens
  2. Tokens regenerate at 0.5 per second
  3. Each login attempt consumes 1 token
  4. When tokens are exhausted, requests receive HTTP 429 (Too Many Requests)

Response When Rate Limited

HTTP/1.1 429 Too Many Requests
Content-Type: text/plain

Too many requests. Please try again later.

Account Lockout

Account lockout prevents password guessing attacks by temporarily blocking login attempts after multiple failures.

Configuration

Setting Value Description
Max Failed Attempts 5 Failures from one address before that email + address pair is locked
Account-wide Threshold 25 Failures across all addresses before the account is locked for everyone
Attempt Window 15 minutes Time window for counting failures
Base Lockout Duration 15 minutes Initial lockout period
Max Lockout Duration 24 hours Maximum lockout after repeated lockouts

How It Works

  1. Failed login attempts are tracked per email address and client address, and separately per email address alone
  2. After 5 failed attempts within 15 minutes from one address, that email + address pair is locked; the account owner can still sign in from another address. For IPv6 clients the "address" is the /64 block. After 25 failed attempts within the window across all addresses, the account is locked for everyone, which stops a distributed guessing campaign; an attacker with many addresses therefore gets at most five times the single-address budget before the account lock, and that lock doubles with each repetition. Knowing a victim's email is not enough to lock them out from their own connection.
  3. Lockout duration doubles with each subsequent lockout (exponential backoff):
    • 1st lockout: 15 minutes
    • 2nd lockout: 30 minutes
    • 3rd lockout: 1 hour
    • 4th lockout: 2 hours
    • ...up to 24 hours maximum

User Experience

Attempt Message
1 "Invalid email or password"
2-4 "Invalid email or password. N attempts remaining."
5 "Too many failed attempts. Account locked for 15 minutes."
During lockout "Account is temporarily locked. Try again in N minutes."

Security Features

  • Non-existent email protection: Failed attempts are tracked even for non-existent email addresses to prevent user enumeration attacks
  • Successful login reset: A successful login clears the failed attempt counters in both scopes. Confirming the password for a sensitive change (a user change, a theme import, the first authenticator setup) does not
  • Automatic cleanup: Stale tracking entries are automatically removed

Two-Factor Authentication

oCMS has a built-in second factor: a six-digit code from an authenticator app (TOTP: 1Password, Bitwarden, Google Authenticator, Microsoft Authenticator and others), with ten single-use recovery codes for a lost device.

Variable Default Description
OCMS_MFA_KEY — Seals the authenticator secrets at rest (min 32 bytes, distinct from OCMS_SESSION_SECRET). openssl rand -base64 32
OCMS_REQUIRE_MFA true in production Every account (admin, editor, public) must pass a second factor; needs OCMS_MFA_KEY

Back OCMS_MFA_KEY up with the database. Without it, nobody can set up an authenticator app, and enrolled users sign in with a recovery code and set their app up again (with the account password, if that was their last code). Demo mode turns two-factor authentication off, authenticators enrolled before the switch included, because the demo accounts are shared.

Sign-in

After the password, an account with an authenticator enters its code at /login/mfa; an account without one, where a second factor is required, sets one up at /login/mfa/setup and sees its recovery codes once. The session is signed in only after that step, so /admin, /account and module routes treat it as anonymous until then. The pending sign-in lasts 10 minutes and ends after 5 wrong codes; wrong codes also count toward the account lockout. Each code is accepted once. Switching OCMS_REQUIRE_MFA on signs out sessions that started without a second factor.

Account security page

/account/security (user menu and account sidebar) sets up, replaces or turns off the authenticator and generates new recovery codes. The first setup asks for the password and every later change for a current code, and setting up, replacing or turning it off signs out the user's other sessions. Turning it off is not offered where the site requires it.

Lost device

  • An administrator resets another user's second factor on /admin/users/{id} with Reset two-factor authentication, confirmed with their own current password; the user is signed out and, where required, sets up a new authenticator at the next sign-in.
  • An administrator with nobody to reset them runs ocms mfa-reset [-db ./data/ocms.db] admin@example.com on the server.

API keys are not affected: they use bearer tokens, not sessions.

Changes that could hand an account over

The user form asks for the acting administrator's current password for a new password or email for any user (the email keys the lockout), giving or taking away the admin role, and a second-factor reset. Wrong attempts count toward the lockout. A content import that overwrites users never grants or removes the admin role. A stolen administrator session therefore cannot set a password of its own, reset an account's lockout by changing its email, create an administrator it controls, or demote the real ones. Activating the DB Manager module asks for the password too, since its raw SQL reaches every table; once active its queries need none, so switch it off when done.

Session Lifetime

Sessions are stored server-side; the session row holds the data and the cookie only carries its token. A session ends after 24 hours regardless of activity, and after 2 hours without any request; each request renews the idle window. This applies to every session, admin and public alike.

Event Logging

All login-related events are logged to the events system:

Event Level Description
Login failed: user not found Warning Attempt with non-existent email
Login failed: invalid password Warning Wrong password for existing user
Login attempt on locked account Warning Attempt during lockout period, at the password step, the code step or authenticator setup during sign-in
Account locked due to failed attempts Warning Account just got locked
Login failed: invalid two-factor code Warning Wrong, expired or reused code at /login/mfa
Recovery code used to sign in Warning Sign-in with a recovery code
Two-factor authentication enabled / disabled Info / Warning Changed on the account security page
Two-factor change refused: invalid code / invalid password / no password / account locked Warning Wrong current code, or wrong or missing password for the first setup, on the account security page, or any second-factor change tried while the account is locked
User change refused: wrong current password / account locked / no current password Warning Acting administrator's password refused on the user form or a second-factor reset (a missing one is not counted)
Module activation refused: … / Module activated with password confirmation Warning, Error / Info DB Manager activation refused or allowed by the password check
Theme import refused: … Warning, Error Theme import refused by the password check (a missing password is logged as "no password" and not counted)
… refused: password could not be verified Error A stored password hash could not be checked at the user form, the first two-factor setup, theme import or module activation (sign-in writes it to the server log only)
Recovery codes regenerated Info New recovery codes issued
Two-factor authentication reset by an administrator / from the command line Warning Reset on the user page or with ocms mfa-reset
User logged in Info Successful login; second_factor metadata names the method

View these events at Admin > Events.

Behind a Reverse Proxy

When running behind a reverse proxy, ensure the proxy passes the real client IP. See Reverse Proxy for configuration.

The login protection middleware uses the client IP resolved by oCMS. Forwarding headers are honored only when the direct peer is listed in OCMS_TRUSTED_PROXIES (see the checklist below); any other peer is identified by its connection address. For a trusted peer the order is:

  1. X-Forwarded-For (the first address from the right that is not a trusted proxy)
  2. X-Real-IP, only when X-Forwarded-For is absent
  3. RemoteAddr (direct connection, or no usable header)

Best Practices

  1. Use strong passwords: Enforce minimum password requirements
  2. Monitor events: Regularly check the events log for suspicious activity
  3. Use HTTPS: Always run behind HTTPS in production
  4. Enable hCaptcha: Add bot protection on the login form
  5. Configure trusted proxies: Set OCMS_TRUSTED_PROXIES for correct IP detection
  6. Keep two-factor authentication required: production requires it by default; back up OCMS_MFA_KEY

Production Deployment Checklist

Before exposing oCMS to the public internet, work through this list:

  1. Rotate the seed admin password. OCMS_DO_SEED=true provisions admin@example.com with the public password changeme1234. Log in and change both the email and password on /admin/users before anything else. Production startup blocks seeding unless demo mode is explicitly enabled, so the exposure window is limited to initial-setup runs — but the first admin session still has to rotate. Once any administrator account exists, seeding no longer recreates admin@example.com, so renaming or replacing it is permanent even if the flag stays on.
  2. Set OCMS_SESSION_SECRET to a freshly generated 32-byte value (openssl rand -base64 32).
  3. Set OCMS_ENV=production so all security defaults apply.
  4. Set OCMS_MFA_KEY to another freshly generated 32-byte value and back it up with the database. Production requires a second factor for every account and refuses to start without the key unless OCMS_REQUIRE_MFA=false.
  5. Configure OCMS_TRUSTED_PROXIES if running behind a reverse proxy — otherwise client IPs used by rate limiting collapse to a single bucket. In production, startup fails while it is unset unless OCMS_REQUIRE_TRUSTED_PROXIES=false. See Reverse-Proxy.
  6. Consider enabling HSTS preload (OCMS_HSTS_PRELOAD=true) once every subdomain has a valid TLS certificate; see the HSTS Preload section in Reverse-Proxy.

Troubleshooting

Legitimate User Locked Out

  1. Wait for the lockout period to expire, OR
  2. Delete the account's rows from the login_protection table. Lockout state is stored in SQLite, so restarting the application does not clear it:
    sqlite3 ./data/ocms.db "DELETE FROM login_protection WHERE email IN ('user@example.com', 'account' || char(31) || 'user@example.com') OR email GLOB 'pair' || char(31) || 'user@example.com' || char(31) || '*'"

Rate Limiting Too Aggressive

  1. Increase burst allowance for more initial requests
  2. Check if users are behind a shared NAT (many users with same IP)

False Positives in Logs

High volumes of "user not found" warnings may indicate:

  1. Legitimate users mistyping email addresses
  2. Automated scanning/attacks (expected and blocked)
  3. Misconfigured client applications

Clone this wiki locally