How PHP Applications Authenticate Users With Sessions and Tokens
Authenticate PHP users with password hashing, secure session cookies, CSRF protection, and token boundaries, including South Florida shared-hosting session risks.
How PHP Applications Authenticate Users With Sessions and Tokens
A developer watching a penetration test sees the tester pull a session ID out of an unencrypted cookie and hijack a user account in under three minutes. The fix is not a new library. It is three cookie flags set before the session begins, a password hash function that costs the server 0.3 seconds per login, and a clear boundary between stateful sessions and stateless tokens. PHP authentication depends on a small set of mechanisms that have been stable for years: password_hash with a cost of 12 for bcrypt, session cookie flags set to secure, httponly, and samesite lax, and a session fixation defence that regenerates the ID after privilege changes. The rest is the configuration you ship and the failure modes you avoid.
PHP Password Hashing
PHP password hashing runs on two functions: password_hash and password_verify. Call password_hash with the PASSWORD_BCRYPT algorithm and a cost of 12. That cost forces roughly 0.3 seconds of CPU work on a modern server, making offline brute-force attacks slow enough to be impractical. If your environment runs PHP 7.4 or later and has libsodium compiled in, switch to PASSWORD_ARGON2ID. Argon2id resists side-channel attacks better than bcrypt and lets you set memory and thread costs alongside time cost. That matters when an attacker can rent GPU time cheaply.
What a Cost of 12 Buys You
A cost of 10 completes in about 0.08 seconds on a typical VPS. A cost of 12 completes in roughly 0.3 seconds. That gap is the difference between an attacker cracking a password in two days versus two weeks with the same hardware budget. Every time you raise the cost by 1, the work factor doubles. Test on your production hardware. A login that takes 0.5 seconds under peak load is acceptable. One that takes 1.5 seconds causes users to hit submit twice and creates duplicate login attempts.
Why password_verify Does Not Need the Cost
The hash string produced by password_hash includes the algorithm identifier, the cost parameter, and the salt, all concatenated into a single 60-character string for bcrypt. password_verify reads the cost from that stored hash and re-applies it. You never store the cost separately and you never write your own salt. The function generates a cryptographically secure salt per call.
The common failure is storing the hash in a VARCHAR(60) column but forgetting to set the collation correctly. Use a latin1 or ascii_bin collation to avoid silent truncation on multi-byte character sets, which breaks password_verify for some users but not others.
Session Cookie Security Flags
The default cookie settings that session_start writes are the claimed-versus-real observation that matters most: no secure flag, no httponly flag, and no samesite attribute. On a site that runs HTTPS, an unsecured session cookie transmits over any accidental HTTP connection on the same domain, exposing it to anyone on the network. Without httponly, a cross-site scripting flaw reads document.cookie and exports the session ID instantly. Without samesite, the browser sends the cookie on cross-site requests. That is how CSRF attacks succeed.
Fix this before session_start fires. Set session.cookie_secure=1, session.cookie_httponly=1, and session.cookie_samesite="Lax" in php.ini, or pass them in the session_set_cookie_params call. The Lax value for samesite blocks cookies on most cross-site POST requests but allows them on top-level navigations. A user clicking a link from an email keeps their session. A malicious form submission from another domain does not.
Set the session name away from the default PHPSESSID with session_name. This removes the fingerprint that signals to an attacker which platform you run. Combine this with session.cookie_domain set explicitly to your domain without a leading dot, which limits the cookie scope to the exact host and not subdomains you do not control.
Session Fixation Defence and Stateful Session Boundaries
Session fixation is the attack where an attacker sets a session ID in a victim's browser, usually via a URL parameter, and waits for the victim to log in. When they do, the attacker uses the same session ID and inherits the authenticated session. PHP does not defend against this. The defence is session_regenerate_id(true) called immediately after a successful password_verify check and after any privilege change, such as role escalation or password reset. The true argument deletes the old session file. The attacker's copy of the ID becomes worthless.
The session mechanism itself has a locking constraint: the default file-based handler serialises writes, so two requests from the same browser using the same session ID queue behind each other. For AJAX-heavy applications, this causes requests to stall visibly. Write session_write_close() early in any request that reads session data but does not need to write it. That releases the lock. For high-traffic applications, move the session storage to Redis or a database with row-level locking. This removes the file I/O bottleneck that kills throughput under load.
The CSRF token is a separate boundary from the session boundary. Store the CSRF token in the session on the server side and embed it in every form as a hidden input, then compare on submission. Renew the token after each use to prevent replay. This is defence in depth: the samesite cookie flag stops most CSRF vectors, but the token catches the ones that slip past when a browser does not support samesite or when an attacker finds a samesite bypass.
PHP MFA Implementation
Multi-factor authentication in PHP adds a second factor on top of the password. The most practical implementation for a custom application is TOTP, the time-based one-time password algorithm standardised in RFC 6238. A user scans a QR code during setup that encodes a shared secret. The authenticator app generates a six-digit code that changes every 30 seconds. Your server checks that code against the current time window.
The PHP implementation needs three things: a library that implements the TOTP algorithm (spomky-labs/otphp is the most maintained at the time of writing), a column in the users table to store the TOTP secret as a base32-encoded string, and a backup-code system for when the user loses their phone. Generate eight one-time backup codes, hash them with password_hash, and store the hashes. When a backup code is used, verify it with password_verify and then delete the hash.
Rate Limiting After MFA Is Active
Rate limiting login becomes essential once MFA is in place because an attacker who has a valid password can burn through TOTP codes. Limit to five failed MFA attempts per user per 15 minutes. Store the attempt count in a cache layer with a TTL of 900 seconds. On the sixth failure, lock the account and notify the user by email. This stops automated TOTP brute-force without locking out legitimate users who mistype a code twice.
Token-Based Auth PHP
Token-based authentication in PHP splits into two distinct protocols that solve different problems. Confusing them causes architecture mistakes that are expensive to undo. JWT is a compact token format. OAuth2 is an authorisation delegation framework. A JWT is a signed JSON payload that your application issues and validates itself. OAuth2 is a multi-party protocol where a client, a resource server, and an authorisation server exchange tokens according to a grant flow.
When to Use a JWT
A JWT makes sense when you need a stateless token that carries user identity and scopes inside the token body, and when the service that validates the token is the same service that issued it. The token is signed with a secret key or a public-private key pair. Validation checks the signature without a database lookup. This is fast, but it means you cannot revoke a JWT once issued except by maintaining a token blocklist, which defeats the statelessness. Set the expiry to 15 minutes or less for access tokens. Use a refresh token stored server-side to issue new access tokens. This limits the window of a stolen JWT to the expiry period.
OAuth2 as the Delegation Framework
OAuth2 is not a token format. It is a set of grant types that allow a third-party application to access a user's resources without seeing the user's password. The authorisation code grant is the one to implement: the user authenticates on your server, your server issues a short-lived authorisation code, and the client exchanges that code for an access token at the token endpoint. The access token can be a JWT, but it does not have to be. The point of OAuth2 is the delegation flow, not the token encoding.
The mistake to avoid is implementing OAuth2 when you need a simple API token for a mobile app that you own. That is the password grant, which the OAuth2 working group has deprecated in favour of the authorisation code grant with PKCE. If the "third party" is your own mobile application, you still need the full PKCE flow because a mobile device cannot keep a client secret confidential. The PHP League's OAuth2 Server package implements the standard grants and handles the cryptographic exchange. Do not write an OAuth2 server from scratch.
What Goes Wrong in Production
The single thing that most often goes wrong with PHP authentication is a production deployment that leaves display_errors on and Xdebug enabled. Stack traces leak file paths, database credentials, and sometimes session data directly into the response body. Set display_errors=Off and log_errors=On in php.ini. Verify that the opcache is enabled and Xdebug is absent from the loaded extensions list before you accept traffic. A deployment checklist that does not include php -m | grep xdebug and a curl -I check for the X-Powered-By header is a checklist that will fail on its first production load.
Common Questions
How do I store PHP sessions securely?
Set session.cookie_secure, httponly, and samesite flags before session_start. Use Redis or database-backed handlers instead of file-based storage. Regenerate the session ID after login.
What is the right bcrypt cost for my server?
Target 0.3 seconds per hash on your production hardware. Start with cost 12 and run a benchmark. Raise or lower by 1 until the hash time lands in the 250-350ms range.
Can I use a JWT for user sessions?
Yes, but you lose the ability to revoke sessions without a blocklist. Use an access token with a 15-minute expiry and a refresh token stored server-side to issue new access tokens.
How do I defend against brute-force login attacks?
Rate limit to five failed attempts per username per 15 minutes. Use a cache layer with a 900-second TTL. After the sixth failure in a window, lock the account and send an email notification.