In this article
April 21, 2026
April 21, 2026

Best practices for secure user authentication

An opinionated checklist for the auth decisions you'll actually have to make.

Authentication is the part of your app that everyone agrees matters and almost everyone ships with at least one quiet bug. The defaults in most frameworks are good enough to get past code review and bad enough to lose you a SOC 2 audit. Stolen credentials are still implicated in roughly 80% of web application attacks per Verizon's annual Data Breach Investigations Report, and the average breach now runs a little under $5 million. The good news is that the core practices have stabilized. The protocols are mature, the libraries are battle-tested, and the patterns that actually work are well understood.

This is a practitioner's checklist. If you're building or auditing an auth system today, these are the decisions worth getting right, in roughly the order you'll hit them.

1. Pick the right protocol, and don't reinvent it

Most authentication failures don't come from cryptographic flaws. They come from teams writing custom token formats, custom session schemes, or custom SAML parsers. You almost never need to.

The boring, correct choices for new applications:

  • OAuth 2.1 with PKCE for any client that runs in a browser or on a mobile device. The implicit flow is dead. If a tutorial still mentions it, the tutorial is dated.
  • OpenID Connect (OIDC) when you need to know who the user is, not just what an app can access. OAuth answers "what can this client do?" and OIDC answers "who is this user?" You almost always want both.
  • SAML 2.0 when you're selling to enterprises that demand it. SAML is older and more painful to implement, but it's still the price of admission for most large customers.
  • WebAuthn / FIDO2 for passwordless and strong second-factor flows.

If you're picking between OIDC and SAML for a new B2B feature, default to OIDC and add SAML when a customer requires it. OIDC is JSON-based, plays well with mobile, and is significantly easier to debug.

The other half of this rule: use a vetted client library. Validating an OIDC ID token by hand is one of the most reliable ways to introduce a critical bug. Pick a library that handles signature verification, issuer and audience checks, and JWKS key rotation for you.

One non-negotiable that's easy to forget: serve everything over TLS. Not just the login page. Every authenticated request, every cookie, every API call. A login form posted over HTTPS is useless if the resulting session cookie travels back over plain HTTP one route later.

2. Make passkeys the default

Passkeys are the most consequential change in consumer authentication in a decade. They're built on WebAuthn, which is now supported across every major browser and platform, including iOS, Android, Windows, and macOS. They're phishing-resistant by design because credentials are cryptographically bound to the origin that registered them. A passkey registered on yourapp.com cannot be used on yourapp-login.com, no matter how convincing the email is.

For developers, the appeal is structural:

  • There's no shared secret to leak from your database. The server stores only public keys.
  • There's no password reset flow to harden, because there's no password to forget.
  • Sign-in conversion improves because Face ID or Touch ID is faster than typing.

The two things to know before implementing:

  1. WebAuthn requires HTTPS. No exceptions in staging, though localhost is exempted for local development.
  2. Passkeys are bound to a domain, including subdomains in some configurations. Plan your auth domain carefully. Passkeys created on accounts.yourapp.com will work on subdomains of yourapp.com but not on a sibling yourapp-admin.com.

The mechanics of registration and login follow a fixed ceremony: the server issues a random challenge, the authenticator signs it with a private key that never leaves the device, and the server verifies the signature against the stored public key. Don't implement the cryptographic verification yourself. Use a maintained server library (go-webauthn, py_webauthn, @simplewebauthn/server) or delegate to an identity provider that handles the ceremony for you.

A reasonable rollout looks like: ship passkeys as the recommended option, keep email plus password as fallback, and allow users to register multiple passkeys (one per device) so losing a phone doesn't lock them out.

3. Hash passwords with a modern algorithm

If you still need to support passwords, the rules are short:

  • Use Argon2id if your runtime has a good implementation. It's the current OWASP recommendation and the winner of the Password Hashing Competition.
  • Use bcrypt if Argon2id isn't practical. It's old, but it's still solid for most workloads. Tune the work factor so a single hash takes around 250 to 500 milliseconds on your production hardware.
  • Do not use MD5, SHA-1, SHA-256, or any unsalted hash. These are designed to be fast, which is exactly the wrong property for password storage.

Always compare hashes with a constant-time function (hash_equals in PHP, crypto.timingSafeEqual in Node, hmac.compare_digest in Python). String equality leaks timing information that attackers can use to recover hashes one byte at a time.

A sensible Argon2id starting point in Node:

  
import argon2 from 'argon2';

const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456,   // 19 MiB
  timeCost: 2,
  parallelism: 1,
});

// Later, on login:
const valid = await argon2.verify(hash, submittedPassword);
  

One more thing that's easy to miss: cap the input length on your password field at something reasonable, like 128 characters. Bcrypt silently truncates inputs above 72 bytes, and unbounded input lengths invite denial-of-service attacks against your hashing function. If you must accept longer passwords with bcrypt, pre-hash with SHA-512 and base64-encode before passing to bcrypt; never silently truncate.

4. Drop the password rules from 2005

The password rules most apps still ship with were written when "P@ssw0rd1" was considered strong. NIST has spent the last several years walking nearly all of them back. The current consensus, codified in NIST SP 800-63B and the OWASP authentication cheat sheet:

  • Length minimums are the only strength rule that matters. Require at least 8 characters when MFA is enabled, at least 15 when it isn't.
  • Allow up to 64 characters or more. Long passphrases are stronger than short obfuscated passwords.
  • Drop composition rules. No "must have uppercase, lowercase, number, and symbol." They push users toward predictable patterns like Password1! and don't measurably improve entropy.
  • Allow every character. Unicode, spaces, emoji, all of it. The only filter should be length.
  • Stop forcing periodic rotation. Mandatory 90-day password changes produce weaker passwords (Spring2025! then Summer2025!), not stronger ones. Rotate when you have a reason: known breach, suspected compromise, role change.
  • Check new passwords against known breach corpora. Run them against Have I Been Pwned's Pwned Passwords API, which uses a k-anonymity model so you never send the full password or hash. A password that's appeared in a public breach is already compromised on day one.
  • Don't break password managers. Use standard <input type="password"> fields. Allow paste into username, password, and MFA fields. Let users tab between fields. Disabling autofill or paste is a common anti-pattern that pushes users toward weaker, hand-typed passwords.

When a user changes their password, require the current password as part of the flow. The classic abuse case: a user signs in on a shared computer, walks away without logging out, and someone else takes over the session. If changing the password doesn't require re-entering the current one, the takeover becomes permanent in seconds.

!!For more, see The developer's guide to strong passwords.!!

5. Layer MFA, but prefer phishing-resistant factors

MFA still blocks the overwhelming majority of automated account takeover attempts. Microsoft's frequently cited number is that MFA prevents 99.9% of automated attacks. That number is real, but the asterisk has grown bigger every year. Modern phishing kits proxy real-time login flows and capture session cookies after MFA completes, which makes weak factors less useful than they once were.

A defensible factor hierarchy:

  1. Passkeys and hardware security keys. Phishing-resistant. The credential is bound to the origin, so a fake site can't replay it.
  2. TOTP apps (Google Authenticator, 1Password, Authy). Not phishing-resistant, but not vulnerable to SIM swap attacks either.
  3. Push notifications with number matching. Better than basic push, which is vulnerable to MFA fatigue attacks.
  4. SMS codes. A last resort. NIST has been deprecating SMS as an authenticator for years, and major banks in Singapore have already phased it out.

Where you can, let users register multiple factors and treat the strongest one as the default. Step-up authentication is also worth implementing: don't require MFA on every page, but do require it for sensitive actions like changing the recovery email, rotating an API key, or transferring funds.

The next layer beyond static MFA is adaptive authentication: re-evaluating risk during the session based on context. Useful signals include device fingerprint, IP reputation, geolocation, ASN, time-of-day patterns, and whether this combination has been seen before for this user. A login from a known device on the user's home network can pass through with a single factor; the same account signing in from a new country on a hosting-provider IP should be challenged for MFA, or blocked outright. The point isn't to interrogate every user. It's to make friction match risk.

6. Use short-lived access tokens with refresh token rotation

Sessions are where most "secure" auth implementations quietly fall apart. The pattern that works in production:

  • Access tokens live 15 to 60 minutes (or less). Short enough that a stolen token is useful for a bounded window.
  • Refresh tokens live days or weeks. Long enough that users aren't constantly re-authenticating.
  • Refresh tokens rotate on every use. When a client redeems a refresh token, the server issues a new access token and a new refresh token, then invalidates the old refresh token immediately.
  • Detect refresh token reuse. If an old, already-redeemed refresh token shows up again, treat the entire token family as compromised, revoke all of them, and force re-authentication.

Reuse detection is the part most teams skip and the part that catches actual attacks. The legitimate client always has the newest refresh token. If an old one comes back, it almost certainly means someone else copied it.

Sample minimal flow:

  
async function refresh(req, res) {
  const { refreshToken } = req.body;
  const record = await db.refreshTokens.findOne({ token: hash(refreshToken) });

  if (!record) return res.status(401).json({ error: 'invalid_token' });

  if (record.usedAt) {
    // Token was already redeemed once. Treat the whole family as compromised.
    await db.refreshTokens.revokeFamily(record.familyId);
    return res.status(401).json({ error: 'token_reuse_detected' });
  }

  await db.refreshTokens.markUsed(record.id);

  const newAccessToken = signAccessToken(record.userId, '15m');
  const newRefreshToken = await createRefreshToken(record.userId, record.familyId);

  return res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
}
  

For browser clients, prefer same-site, secure, HttpOnly cookies for refresh tokens. They aren't reachable from JavaScript, so cross-site scripting can't steal them directly. Access tokens can sit in memory for the lifetime of the page.

7. Validate every JWT properly

A JWT that you don't fully validate is just a string of trust on the wire. Every consumer of a token needs to check:

  • The signature, against the public key from the issuer's JWKS endpoint. Cache the keys, but respect their rotation.
  • The iss (issuer) claim matches your identity provider exactly.
  • The aud (audience) claim matches your service. Tokens minted for a different audience should be rejected, even if the signature is valid.
  • The exp and nbf claims. Tokens past their expiration should be rejected; tokens not yet valid should also be rejected.
  • The algorithm in the header matches what you expect. Reject alg: none outright, and reject HS256 tokens if your service is configured for RS256. The classic "alg confusion" attack swaps an asymmetric algorithm for a symmetric one and signs the token with the public key.

Use RS256 (or EdDSA) in preference to HS256 for any system where the issuer and verifier are different services. Symmetric signing means every verifier holds the secret that can also mint tokens, which makes key compromise much more dangerous.

For key rotation, publish a JWKS with multiple active keys identified by kid. Verifiers select the right key from the kid header. When you rotate, add the new key first, give clients time to fetch it, then retire the old one.

8. Store tokens in the right place

The browser storage debate has a clean answer once you separate threat models.

  • HttpOnly, Secure, SameSite=Lax (or Strict) cookies are the right home for refresh tokens and session identifiers. They survive a cross-site scripting incident better than localStorage because JavaScript can't read them.
  • Memory (a JavaScript variable in your auth context) is the right home for short-lived access tokens. They die with the tab, which is fine because you can mint a new one from the refresh token.
  • localStorage is acceptable only when you've combined it with strict refresh token rotation and reuse detection. Even then, it's a tradeoff. A successful XSS attack against a localStorage token gives the attacker everything until rotation kicks in.

Set cookies with SameSite=Lax by default, Secure always, and HttpOnly for anything the JavaScript layer doesn't need to touch. Add a CSRF defense (double-submit cookie or origin checking) for state-changing requests if you're using cookies for auth.

!!For more on this, see JWT storage 101: How to keep your tokens secure.!!

9. Rate limit and lock out smartly

Brute force and credential stuffing attacks are not subtle. They're noisy and constant, and rate limiting is the cheapest defense you'll deploy.

A few patterns that hold up:

  • Tie failed login counters to the account, not just the source IP. Attackers rotate through residential proxy networks; a per-IP counter sees one attempt per address and never trips. A per-account counter sees the whole campaign.
  • Add an IP and ASN reputation signal too. Per-account lockouts protect users; per-IP throttling protects your infrastructure from being a load-test target.
  • Lock with exponential backoff, not permanent locks. Permanent lockouts let attackers turn rate limiting into a denial-of-service tool against legitimate users. A determined attacker can lock every account in your system if you let them.
  • Add a CAPTCHA challenge or a proof-of-work step after a small number of failures, before locking. This catches scripted attackers without inconveniencing real users on the first wrong password.

Rate limiting belongs on every authentication endpoint, including password reset, MFA verification, and refresh token endpoints. A reset endpoint without rate limiting is an account enumeration oracle.

10. Don't let your endpoints leak which accounts exist

Account enumeration is the quiet vulnerability you'll find in most apps that haven't been audited. The pattern is simple: an attacker queries your endpoints to figure out which email addresses have accounts, then targets those accounts with credential stuffing or phishing. Every difference in your responses, however small, is an enumeration signal.

The endpoints that leak most often:

  • Login. "Invalid password" and "no such user" should be the same message: "Invalid email or password." Same wording, same HTTP status code (a 200 for one and a 403 for the other gives the game away even when the message is generic), and ideally the same response time.
  • Signup. "This email is already registered" tells an attacker the address has an account. Better: send a confirmation email regardless and respond with "We've sent a verification link to that address." If the address is already registered, the email can say so privately to whoever owns it.
  • Password reset. Always return a generic "If an account exists with this email, we've sent a reset link." Never confirm or deny existence.

Timing is the gotcha most teams miss. If your login handler short-circuits on "no such user" in 5 milliseconds but takes 200 milliseconds when the user exists (because Argon2id ran), an attacker enumerates by latency without ever reading your response body. The fix: always run the password hash, even against a static dummy hash, when the user doesn't exist. The constant-time comparison from section 3 applies here too.

A related class of leak: sequential or guessable user IDs. If your URLs look like /users/1042, attackers can iterate and discover both the existence and the volume of your user base. Use random identifiers (UUIDs or similar) for any ID that appears in URLs, error messages, or API responses.

11. Treat password reset (and email change) as a critical attack surface

A vulnerable reset flow bypasses every other protection you've built. MFA, strong passwords, and device fingerprinting are all moot if an attacker can reset the password.

The non-negotiable list for password reset:

  • Reset tokens must be cryptographically random (32 bytes from a CSPRNG, base64url-encoded).
  • Reset tokens must be single-use. Invalidate immediately when the password changes or when a new reset is requested.
  • Reset tokens must be short-lived, typically 15 to 60 minutes.
  • Store only the hashed token server-side. If your database leaks, the tokens shouldn't be usable.
  • Require re-authentication before changing the recovery email or phone number.
  • After a successful reset, invalidate all existing sessions for that user. If the reset was triggered by an attacker, the legitimate user will sign back in; if the user did it themselves, signing in again is a small price for kicking out anyone else.

Email and recovery-method changes deserve the same care, because they're the other lever for permanent account takeover. The pattern OWASP recommends: require an active authenticated session, prompt for the user's current password (or an MFA challenge if MFA is enabled), and send two emails. One goes to the current address as a notification with a link to report unexpected activity. The other goes to the new address as a confirmation that the user must click before the change takes effect. Both links carry single-use, time-limited tokens. Skip either email and you've built a one-click account takeover.

12. Log every authentication event

Authentication logging is what lets you detect breaches in days instead of months. The 2024 IBM Cost of a Data Breach Report puts the average detection time for credential-based incidents at 292 days. Better logs cut that meaningfully.

Log every successful login, every failed login, every MFA challenge (success and failure), every token refresh, every password reset, and every change to a user's auth settings. For each event, capture timestamp, user ID, IP address, user agent, geolocation, and the outcome.

A few things to do with those logs:

  • Alert on impossible travel. A login from New York followed by one from Lagos eight minutes later is a real signal.
  • Alert on credential stuffing patterns. Many failed logins across many accounts from a small IP range is the signature.
  • Surface session activity to users. A "recent sign-ins" page in account settings turns users into co-defenders. They notice things your detection rules miss.
  • Don't log secrets. Never write the password, the token value, or the MFA code into a log line. Hash or redact before persistence.

Building vs. buying

You can build all of this. Plenty of teams have. The honest tradeoff is that authentication is a category where the marginal cost of getting one detail wrong is high and the upside of a custom implementation is usually small. If your product isn't an identity product, the time you spend hardening session rotation is time you don't spend on the thing your users are actually paying for. We've made the longer case for buying over building specifically for SSO and SCIM, where the long tail of enterprise edge cases tends to dwarf what teams initially scope.

If you do decide to buy, WorkOS handles the parts of authentication most teams shouldn't write themselves: enterprise SSO, directory sync via SCIM, MFA, passkeys, and the session lifecycle work covered above. It's free up to a million users, with no feature gates on auth methods. Sign up and get back to building your product.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.