A reset link that works twice hands the account to whoever opens it second. Authentication best practices for a small app come down to six flows, each tested four ways: success, rejected, expired and reused. Of the 21 third-party apps I audited in June and July 2026, 11 had unauthenticated endpoints doing privileged work.

What authentication best practices cover: the six flows, and what each has to do

Six authentication flows have to hold in any web app: signup, login, logout, password reset, email verification and OAuth callbacks. Each one is run on four paths: success, rejected, expired and reused. A flow that has not been through all four is untested.

The opener’s 11 out of 21 third-party apps are public and held-out apps I audited over those two months: a set I chose, not a random sample, and no rate for AI-built apps in general. The whole access area, authorization included, sits in the authentication checklist; this page stays on the account flows. The best practices below hold for authentication in any web app or SaaS application where a user signs in through a login form or a provider’s button.

FlowWhat must be trueThe four paths to run
SignupThe account starts unverified and holds no privileges until the address is confirmedNew address; an address already registered; an expired confirmation link; the same link twice
LoginOne error message for a wrong email and a wrong password; a limit on failed attemptsRight password; wrong password and unknown email; an expired session; a session ID reused after logout
LogoutThe session is ended on the server, and clearing the cookie is the smaller half; on a token session the refresh token is revoked and a short expiry does the restLog out; log out with no session; a token past its expiry; the refresh token replayed
Password resetOWASP’s reset rules, listed in the next sectionA valid token; a wrong token; a token past its expiry; the same token twice
Email verificationThe code or link is single use, expires after an appropriate period and is resend-limitedThe right code; a wrong code; an expired code; the same code twice
OAuth callbacksThe state value is checked, and an account is linked only on a verified emailA clean sign-in; a wrong state or an unverified email; a stale callback; the same callback twice

The logout row needs a word of explanation. On a token session, an access token issued before logout can outlive it: Supabase says access tokens of revoked sessions “remain valid until their expiry time”, Firebase ID tokens “last for an hour” and a revoked one is detected only by asking Firebase’s backend for the token’s status, and Auth.js says a JWT stays valid “until it expires” if the user has it saved elsewhere. Where OWASP has words for a rule, the table uses them; the rest of these authentication best practices are my own review list, and OWASP is cited by name where it applies.

Those four paths are my test design for signup and login edge cases and for the other four flows too; an auth flow that only ever passes its success path has met none of these best practices. Authorization is the other half of authentication and authorization best practices: it decides what a signed-in user may do, and the rule that keeps one customer’s rows away from another’s is multi tenant data isolation. API keys and service-to-service logins follow different rules, covered in API authentication best practices.

If you plan to create your own authentication rather than hand it to an identity provider, the six flows and the best practices for each stay the same; the choice between the two is the last part of the next section.

In the Production Hardening Sprint, this is deliverable 1.1, whose published wording is “Review and fix signup, login, logout, password reset, email verification, and OAuth callbacks.”

How does authentication work in a web app? Forms-based authentication, and what a login form has to do

Authentication works, in a web app, by checking a secret the person proves they hold and then remembering the result. Forms-based authentication is the usual shape: a login page, then a cookie that identifies the user on every later request. The form posts over TLS, gives one error message for every failure and limits attempts.

Microsoft’s article on ASP.NET forms-based authentication shows the classic version: a visitor who has not logged on is redirected to the logon page, the user is then identified by the authentication cookie, and because the cookie is what identifies them, the article says you “may want to use” SSL so nobody can tamper with it.

The browser’s own sign-in prompt works differently, and basic authentication best practices start from that difference: under RFC 7617 the browser may send the stored credentials again with later requests to the same protected area without being asked, and, in my reading, it gives the app no logout page of its own.

A login form today has five jobs. It posts only over TLS, which OWASP’s Authentication Cheat Sheet requires for the login page and every authenticated page after it. It answers every failure with the same message, it limits attempts, it sets the session on the server, and, as my working rule, it keeps the password field out of every log line.

What goes wrong without it

Login bypass, on a website or in an app, is any path into an account or a privileged action that skips a valid sign-in. The table lists five ways an app’s own flows can open one.

FailureThe flowThe symptomWhere this page tests it
A reset token that works twice or never expiresPassword resetA forwarded or old reset email opens the account againThe reset row, expired and reused cells
A login that answers “wrong password” and “no such user” differentlyLoginWhich addresses have accounts leaks out (OWASP asks for one generic message, as in the table above)The login row, rejected cell
An OAuth callback that trusts an email the provider has not verifiedOAuth callbacksA stranger with a matching address lands in the accountThe OAuth row, rejected cell
A signup that grants privileges before verificationSignupAn address nobody has confirmed can use paid or admin featuresThe signup row, success cell
A privileged endpoint with no session check at allAnyThe action runs for anyone who sends the requestThe no-session line under the verify table

Login bypass, auth bypass and authentication bypass are the usual names for the last three: a way in that never meets the check the flow was built to make. Row three is the OAuth vulnerability that lives in the app’s callback code rather than at the provider, so, in my reading, an OAuth security review that stops at the provider settings misses it, and no attack on the protocol is needed for it to matter.

The fifth row is the one behind the opener’s 11 of the 21 third-party apps. In the same June and July 2026 audits, the Authentication pillar averages 52.8 out of 100, scored on 14 of the 21 third-party apps. Both figures describe public and held-out apps I selected for audit, which makes them a chosen group and not a sample that stands for all apps.

In the OWASP Top 10 2025 edition this is OWASP’s A07 category, “Authentication Failures”; the 2021 edition called it “Identification and Authentication Failures”, and the IAAA failures label you may see in course material on the OWASP Top 10 2025 does not appear on OWASP’s A07 page.

In one of my own apps, five security controls (the secure cookie flag, email verification, the CORS lock, the boot check for the token secret, and the stricter login rate limit) were switched on only when NODE_ENV equals production, and the deploy never set it. All five were in the code, and none of them was running for real users. The lesson I take from it: a control that only turns on when a variable is set stays off until someone checks that it is on.

How to do it on the common stacks

Doing it on any stack means one error message for every failure, a limit on attempts, a slow password hash, a server session ended on logout, single-use expiring tokens for reset and verification, and an OAuth callback that never links an unverified email. The package gives you the flows; the tests are yours.

The rules below are OWASP’s and the standards’ words where a source is cited, and my working rules where none is; they are not tests I ran.

Signup, login and logout: the server-side rules

  1. 01 One error message for every login failure, whatever was wrong
  2. 02 A limit on failed attempts counted per account, with a way back in for the real user
  3. 03 Passwords hashed with a slow, salted algorithm, never stored as plain text
  4. 04 A session created on the server after login and destroyed on logout
  5. 05 A remember-me cookie scoped to your own domain, with an expiry
  6. 06 Signup creates an unverified account with no privileges
  7. 07 One audit log line for each signup, login, failed login, logout and reset

OWASP’s Authentication Cheat Sheet wants a generic error from login, password reset and password recovery alike, whether the user ID or password was wrong, the account does not exist, or it is locked or disabled. It also ties the failed-login counter to the account itself rather than the source IP address, and it suggests letting the forgotten-password flow work on a locked account so lockout cannot be used to shut real users out. The general limit on every route is a separate control: rate limiting in API routes.

To store passwords in a database, OWASP’s Password Storage Cheat Sheet puts Argon2id first, scrypt next, bcrypt for legacy systems and PBKDF2 where FIPS-140 compliance is required, with a unique salt on each password. Length rules and the rest of the policy are a separate topic: NIST minimum password length and password storage.

Logout is where token sessions differ. Supabase’s sign-out revokes refresh tokens on the server, Firebase’s Admin SDK can revoke a user’s refresh tokens, and Auth.js destroys the cookie and relies on shorter session expiry for JWT sessions, which its Credentials provider always uses: per the NextAuth.js docs it “can only be used if JSON Web Tokens are enabled for sessions”. My working rule follows from that: logout revokes the refresh token where there is one, and a short access-token expiry closes the gap. The session controls themselves, expiry and revocation included, come under JWT security.

Password reset: how to build a secure password reset flow

The password recovery best practices here come from OWASP’s Forgot Password Cheat Sheet, and the six rules below keep its words; the length of the expiry is my working rule, not OWASP’s.

  1. 01 Return a consistent message for both existent and non-existent accounts, in a uniform response time
  2. 02 Generate the token with a cryptographically safe algorithm, long enough to resist brute force, and store it securely
  3. 03 Make the token single use and expire it after a short period, stated in minutes in the email
  4. 04 Change nothing on the account until a valid token is presented
  5. 05 Give the reset page a noreferrer referrer policy and rate limit the token URL
  6. 06 After the reset, ask the user whether to end their other sessions, or end them automatically

Password reset security best practices only hold if the server enforces them at the moment the token is redeemed. Here is that check in generic server code:

// Redeem a reset token: hash lookup, expiry, single use
async function redeemResetToken(rawToken: string, newPassword: string) {
  const row = await db.resetTokens.findByHash(hash(rawToken));
  if (!row || row.usedAt || row.expiresAt < now()) return fail(GENERIC_LINK_ERROR);
  // Mark it used in the same conditional update, so two clicks cannot both win
  const claimed = await db.resetTokens.markUsedIfUnused(row.id);
  if (!claimed) return fail(GENERIC_LINK_ERROR);
  await db.users.setPasswordHash(row.userId, await slowHash(newPassword));
  await db.sessions.endAllFor(row.userId); // or ask the user first
  return redirectToLogin(); // no automatic sign-in after a reset
}

The last line follows OWASP’s “Don’t automatically log the user in” after a reset. In my reading, a password reset link not working has three app-side causes: the token was already used, it expired, or the email was built with another environment’s address, so the link points at staging or at a local machine.

The reset and verification emails themselves, how they are sent, their templates and what to check when one never arrives, are a separate job: how to send verification email.

Email verification codes: single use, short expiry, and the resend limit

An email verification code is single use, expires quickly, is compared in constant time, has a resend limit per address, and unlocks privileges only after it succeeds. When codes do not arrive, check the app’s sending setup before the recipient’s inbox.

  1. 01 Single use: the code stops working once it succeeds
  2. 02 A short expiry, stated in the email
  3. 03 Compared in constant time on the server
  4. 04 A resend limit per address
  5. 05 Privileges unlocked only after the code succeeds

That is my working list for any email verification flow, built on OWASP’s token rules from the reset section. An email verification flow example that meets all five: at signup the server stores a hash of a random code with its expiry and marks the account unverified; when the code comes back, the server compares it, deletes it, and only then marks the account verified. To implement email verification this way, keep the verify and resend steps of the flow as separate server endpoints, each limited per address. To generate an email verification link instead of a code, put the same random token in the URL and hold it to the same five rules.

Firebase Authentication runs its own email verification workflow: Firebase’s user management docs send the message with the sendEmailVerification method and can pass a continue URL back to the app; how long that link lasts is not stated on that page. Supabase calls the same step email confirmation and turns it on by default for hosted projects but off for self-hosted projects and local development, so a confirmation flow tested only on a laptop may never have run.

When email verification is not working, start on the app’s side. An email verification code not received can come from the project’s built-in email sender and its limit; on Supabase, that is the Supabase email rate limit. Nobody getting the confirmation email at all, and users locked out after email verification, are covered in why users can’t log in to your app. Sending and deliverability checks are their own job, separate from these five rules. For the person waiting for a code, the spam folder and the address it was sent to come first.

Social and OAuth sign-in: callback errors, provider setup, and when OAuth is the wrong tool

OAuth callbacks fail for four reasons worth checking first: the published address is not on the provider’s redirect list, the state value is missing or does not match, the provider returned an email it has not verified, or the server-side token exchange failed. Never link an account on an unverified address.

When an OAuth callback fails after login at the provider, the table maps what you see to the cause and the fix.

Callback errorCauseFix
Redirect address rejectedThe published address is not on the provider’s list; RFC 9700 has servers compare it by exact string match, localhost ports of native apps asideAdd the exact published address, as the login article above walks through
State missing or not matchingThe state was never stored, expired, or belongs to another browser sessionStore a one-time state bound to the browser session and refuse any mismatch
Email not verifiedThe provider sent email_verified as false, or left it outNever link or create an account on it; key the account on the provider’s issuer and subject identifiers
Token exchange failedWrong client secret, a redirect that differs from the one in the request, or a code already usedLog the provider’s error from the token request; a code is exchanged once

RFC 9700 says clients must prevent CSRF, may rely on PKCE for it where the authorization server supports PKCE, get it from the nonce in OpenID Connect flows, and otherwise must use “one-time use CSRF tokens carried in the state parameter that are securely bound to the user agent”. It also has authorization codes invalidated after their first use, one reason a replayed callback fails.

OAuth is an authorization framework for granting limited access. The OpenID Connect Core specification calls OpenID Connect “a simple identity layer on top of the OAuth 2.0 protocol”, and its email_verified claim is true only when the provider “took affirmative steps” to confirm the address; the same spec says claims such as email “MUST NOT be used as unique identifiers for the End-User”.

Setting up an OAuth provider on a managed backend, such as Supabase with Google or LinkedIn, is a page in that backend’s own docs; the callback rules above apply whichever provider you connect. Single sign-on for a SaaS, where a customer connects its own identity provider over SAML or OIDC (the OAuth federation case), is a separate request with its own cost, covered in a customer asked you to add SSO. For a web app’s own users, the OAuth alternatives are email and password or passkeys, held to the rules above.

Auth packages by framework: what Passport, Devise, Django and the rest actually give you

The authentication best practices are the same on every stack, whether it is React, Angular or Vue on the front, a Next.js or Node.js server, ASP.NET, a Rails API, PHP, a SPA, a React Native mobile app or a websocket server; what changes is which package writes the flows. The middle column is each project’s own description; the last column is my reading.

FrameworkThe packageWhat it gives you (its own words)What it leaves to you (my reading)
Node and Next.jsPassport, passport-saml, Auth.js, Better AuthPassport: “authentication middleware for Node.js”; passport-saml: “a SAML 2.0 authentication provider for Passport”; Auth.js: “now part of Better Auth”; Better Auth: “Sessions, email verification, and password reset included”Lockout, account-linking rules and the four-path tests
RailsDevise, the Clearance gemDevise: “a flexible authentication solution for Rails based on Warden”, with modules that confirm accounts and reset passwords; Clearance: “Rails authentication with email & password”Turning on the modules you need, and the tests
Djangodjango.contrib.auth, django-hijackDjango: “user accounts, groups, permissions and cookie-based user sessions”; django-hijack: “admins can impersonate and work on behalf of other users”Login throttling and OAuth, which Django’s docs leave to third-party packages; locking impersonation to trusted staff
Gogorilla/sessions”cookie and filesystem sessions and infrastructure for custom session backends”The signup, reset and verification flows themselves
ASP.NETASP.NET Core Identity”Manages users, passwords, profile data, roles, claims, tokens, email confirmation, and more.”The lockout and confirmation settings you choose, and the tests
PHPLaravel starter kits”scaffolding your entire authentication system”Reviewing what the scaffold generated, and the tests
Google APIs from Nodegoogle-auth-library”client library for using OAuth 2.0 authorization and authentication with Google APIs”; the repository is deprecated and moved to google-cloud-node-coreA login system: it is a client library, not one
SPA, React Native, mobileThe same server packageNot stated: no package of its ownThe same server rules, plus where the token is stored on the device
WebsocketsThe same server packageNot stated: no package of its ownAuthenticate on connect and check again on reconnect
Self-hosted providerKeycloak”a single sign on solution for web apps and RESTful web services”A password policy and a tight redirect list

The Google row is easy to misread: the google-auth-library package on npm lets a server call Google APIs and does not give your users a login page. Better Auth lists the flows as included; the best practices around them and the tests that prove them are still yours. Keycloak production hardening starts with two warnings in its own guide: a newly created user space starts with no password policy attached, and the full wildcard redirect URI should not be used in production. Every package gives you flows, and none of them runs your tests.

Using a managed identity provider instead of writing the flows yourself

A managed, cloud-based identity provider gives you the six flows and password storage, and multi-factor sign-in on the plans that include it, and leaves you the redirect lists, the email sending, the authorization rules and the tests. Moving to one makes sense when hand-written flows have never been tested.

What each one’s own docs say, for sign-up, reset and a second factor:

  • Supabase Auth supports password, magic link, one-time password, social login and single sign-on, and runs multi-factor sign-in through an authenticator app or phone messages, with phone MFA listed among its billed add-ons.
  • Firebase Authentication sends verification and password reset emails from its SDK, and its docs offer SMS multi-factor authentication to projects upgraded to Firebase Authentication with Identity Platform.
  • Auth0 offers several ways to reset a database user’s password, and its pricing page lists “Pro MFA Factors” as not included on the Free plan.
  • Clerk’s free Hobby plan includes “APIs and prebuilt UIs for sign-up, sign-in, and user profile”, and its pricing page lists multi-factor authentication as not included on Hobby and included on Pro.
  • Amazon Cognito user pools handle self-service sign-up and offer SMS, email or authenticator-app codes as a second factor; a pool with an active email setup on your own Amazon SES resources and the Essentials or Plus feature plan gets email codes as an available method.

In my reading, four things stay with you whichever provider you pick: the redirect lists you register, the domain the emails come from, who may see which rows, and whether any of it was tested. I’d keep hand-written flows when the users already live in the app’s own table or one of the flows is unusual, and I’d move when the flows were generated and never run through the four paths. The move is a project of its own, with every existing user carried across. CIAM, short for customer identity and access management, is the industry’s name for these products, and CIAM authentication best practices are the same six flows seen from the provider’s side.

How to verify it

Authentication is verified with a table of 24 cells: six flows by four paths, success, rejected, expired and reused, each marked pass or fail with a date. The reused reset token and the unverified OAuth email are the two cells I would run first.

FlowSuccessRejectedExpiredReused
SignupNew account exists, unverified, no paid or admin featuresExisting address: the same response as a new oneExpired confirmation link: refused, a new one offeredConfirmation link used twice: second use refused
LoginRight password: a new session ID, set on the serverWrong password and unknown email: the same message, attempts limitedExpired session: sent back to loginSession ID from before logout: refused
LogoutSession ended, protected page sends you to loginNo session: a plain redirect, no error detailAccess token past its expiry: refusedRefresh token replayed after logout: refused; an access token may keep working until its expiry, so record that time instead of a fail
Password resetValid token: password changed, other sessions ended or offeredWrong token: refused, account unchangedToken past its expiry: refusedSame token twice: second use refused
Email verificationRight code: account verified, features unlockedWrong code: refused, attempt countedCode past its expiry: refused, resend offeredSame code twice: refused
OAuth callbacksMatching state and verified email: signed inWrong state, or a provider email marked unverified: refused, no account linkedCallback after the state expired: refusedSame callback URL twice: refused

Copy the table, replace each expectation with pass or fail and the date you ran it, and keep the filled table next to the test script that produced it. That pair is the evidence. Add one line per privileged route as well: the same request with no session must be refused. Replays that test another user’s access use the second account’s own session; those tenant tests belong to multi-tenant data isolation, a separate control. Session tests, expired and revoked sessions, sit beside this in the JWT security guide.

Without a test script, two of these cells can be checked in a browser: five wrong passwords in a row, and a reset or sign-in link used a second time, both described in how to check an AI-built app yourself.

In the sprint, deliverable 1.1 is verified this way: we “exercise successful, rejected, expired, and reused-token paths for each supported flow”, the same four paths as the table above.

OWASP A07: identification and authentication failures, and how each one is tested

The OWASP Top 10 entry for identification and authentication failures, in its current edition, describes the problem as present “when an attacker is able to trick a system into recognizing an invalid or incorrect user as legitimate”, then lists the weaknesses that may cause it. Each one below maps to a cell in the table above:

  • “Permits brute force or other automated, scripted attacks that are not quickly blocked”: the login row, rejected cell.
  • “Allows use of weak or ineffective credential recovery and forgot-password processes”: the password reset row, all four cells.
  • “Uses plain text, encrypted, or weakly hashed passwords data stores”: no cell tests it; read the hashing call in the code.
  • “Reuses the same session identifier after successful login”: the login row, success cell, with the session ID compared before and after.
  • “Does not correctly invalidate user sessions or authentication tokens”: the logout row, reused cell.
  • “Has missing or ineffective multi-factor authentication”: outside these six flows.

On passwords, the OWASP Top 10 entry points to NIST 800-63b for length, complexity and rotation policy. OWASP’s Authentication Cheat Sheet is the control list behind the category, and the closest thing to one page of OWASP authentication best practices; A07 names it in its references. Multi-factor sign-in for owner and admin accounts is its own job, covered in implement two factor authentication.

Where the sprint does this

Beside 1.1, the sprint’s authentication area includes deliverable 1.6, where we implement rate limits and brute-force protections on authentication and recovery endpoints ; 1.2, where we verify expiry, refresh, and session revocation on logout and password change ; and 1.11, where we enforce a second factor on every owner and admin account. Every result is recorded in the production readiness report, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. We work inside the existing codebase, and the app’s current framework and hosting setup are our starting point. The full list is in the authentication deliverables in the published scope.

Common questions about login, reset and OAuth flows

What are some best practices for authentication?

Hold six flows to written rules (signup, login and logout, password reset, email verification, and every OAuth callback), and test each on four paths: success, rejected, expired and reused. Under that sit a generic error for every failed login, a cap on attempts, TLS on the login page and every page after it, and passwords kept as slow, salted hashes such as Argon2id, bcrypt or PBKDF2.

What is the 8 4 rule for passwords?

As far as I can tell from how the phrase is used, it means a password of at least 8 characters drawn from 4 character types. NIST’s current guidance, SP 800-63B-4, disagrees on both halves: verifiers “SHALL NOT impose other composition rules”, and a password used as the only factor must be at least 15 characters, though one used only as part of multi-factor sign-in may be shorter, down to a minimum of eight.

Why is it a bad idea to use OAuth 2.0 for authentication?

OAuth 2.0 is an authorization framework: it lets an app obtain limited access to a service on a user’s behalf. OpenID Connect, the identity layer built on top of it, is what enables an app to verify who the user is. My working rule: a “sign in with” button should run OpenID Connect and check the verified-email claim before it links any account.

Why am I not receiving the 6 digit verification code?

If you are the one waiting, check the spam folder and the address the code went to. If you run the app, check the sending side first: a project still on its provider’s built-in sender can hit that sender’s limit before any inbox sees the code. On Supabase, that limit is covered in the Supabase email rate limit guide.

How are passwords stored in a database?

Never in plain text, and in almost all circumstances not encrypted either: OWASP says passwords should be hashed with a modern, slow, salted algorithm, and names Argon2id, bcrypt and PBKDF2 as examples.