Back to Blog

8 SaaS Authentication Mistakes (and How to Do It Right)

Published: June 29, 2026
8 SaaS Authentication Mistakes (and How to Do It Right)

A slow query annoys your users. A broken authentication flow ends your company. Most SaaS authentication mistakes are not exotic zero-days — they're the same handful of misconfigurations that let an attacker step across a tenant boundary and pull another customer's data. When that happens you're not looking at a bad afternoon; you're looking at contract losses, regulatory fines, and a breach disclosure. And it's not rare: stolen credentials are the initial action in about 24% of breaches (Verizon DBIR 2024), and the average breach now runs $4.88M (IBM, 2024).

The good news is that most SaaS authentication mistakes are the same handful, repeated across codebases, and each has a known fix. Here are the eight we find most often when we review a client's auth layer — and how to do each one right. The thread running through all of them: never trust the client, never roll your own crypto, and make every token revocable.

SaaS authentication mistakes start at the password layer — get the primitives right

Mistake 1: Static Refresh Tokens (No Rotation)

The common setup — a short access token plus a long-lived refresh token — is fine until the refresh token is stored statically and never rotated. Steal it once and an attacker mints new access tokens forever, invisibly.

The fix: refresh token rotation (RTR). Every time a refresh token is used, invalidate it and issue a new one. If a used token shows up again, that's a replay — kill every session for that user. The deeper mechanics are in our JWT refresh token implementation guide, but the core is a single transaction:

TypeScript
1async function rotateSession(submittedToken: string) {
2  return prisma.$transaction(async (tx) => {
3    const session = await tx.userSessions.findUnique({ where: { token: submittedToken } });
4
5    // Unknown token: reject, reveal nothing.
6    if (!session) throw new UnauthorizedException('Invalid session.');
7
8    // Known but already used: this is a replay. Revoke every session for the user.
9    if (session.isUsed) {
10      await tx.userSessions.deleteMany({ where: { userId: session.userId } });
11      throw new UnauthorizedException('Token reuse detected. All sessions revoked.');
12    }
13
14    await tx.userSessions.update({ where: { id: session.id }, data: { isUsed: true } });
15    return tx.userSessions.create({
16      data: { userId: session.userId, token: generateSecureToken() },
17    });
18  });
19}

Mistake 2: Storing Tokens in localStorage

localStorage is readable by any script on the page — including a compromised npm package three dependencies deep. That's the XSS jackpot: one injected script and every session token walks out the door.

The fix: store tokens in HttpOnly, Secure, SameSite=Strict cookies so JavaScript can't touch them. You give up a little frontend convenience and gain immunity to the single most common token-theft path. It's a trade you take every time.

Mistake 3: Putting Secrets in the JWT Payload

A JWT is base64-encoded, not encrypted. Developers see the gibberish and assume it's a vault, then stuff internal database IDs, plan limits, and role flags inside it. Anyone can paste that token into jwt.io and read your entire data model.

The fix: keep payloads minimal — an opaque sub like a random UUID, an expiry, and nothing sensitive. If a request needs roles or tenant scope, look them up server-side from a low-latency cache during validation. The token proves who; your server decides what they can do.

Reviewing the auth layer — most SaaS authentication mistakes are caught in code review

Mistake 4: Access Tokens You Can't Revoke

Stateless tokens are great for performance — your API verifies a signature locally, no database round trip. The downside: a stolen token, or a token belonging to a user who just changed their password, stays valid until it expires.

The fix: a hybrid Redis blocklist. Keep access tokens short (under 15 minutes) and, for logouts and security events, check the token ID against an ultra-fast Redis blocklist so revocation is instant:

TypeScript
1async function isTokenValid(jti: string, signature: string): Promise<boolean> {
2  if (await redis.get(`blocklist:${jti}`)) {
3    throw new UnauthorizedException('Token has been revoked.');
4  }
5  return verifySignature(signature);
6}

You get stateless speed on the happy path and a real "log out everywhere" button when you need it.

A reset link that's valid forever is an account-takeover waiting in an old inbox. Intercept a months-old email and you're in, no warning raised.

The fix: reset tokens expire fast (under 15 minutes), are single-use, and are hashed in the database with the user's current password hash mixed in. The moment the password changes, every outstanding reset token is invalidated automatically. The OWASP Authentication Cheat Sheet is the reference worth bookmarking here.

Mistake 6: Leaking Accounts via Email Enumeration

"Incorrect password" tells an attacker the email is valid. "No such account" tells them it isn't. Either way your login screen just became a tool for mapping which corporate accounts exist on your platform.

The fix: uniform responses, every time. "If an account matches that address, we've sent a secure link." Same message, same status code, and watch your timing too — a fast rejection versus a slow password comparison is its own tell.

Mistake 7: Trusting Client-Supplied Tenant IDs (BOLA)

Access control done right closes the worst SaaS authentication mistakes — a fingerprint scanner at a secure door

This is the multi-tenant killer. Scoping a query by a tenant_id that came from a request header is an open door:

SQL
1-- Vulnerable: tenant_id comes from the client and can be edited in dev tools
2SELECT * FROM invoices
3WHERE id = :invoiceId AND tenant_id = :clientSuppliedTenantId;

Change that value in the browser network inspector and you're reading another company's invoices. This is Broken Object-Level Authorization, the No. 1 API security risk.

The fix: derive tenant scope only from signed server-side token claims, never from client input — and back it with PostgreSQL row-level security so the database itself refuses cross-tenant rows even if an application query forgets the filter. Defense in depth, because the one query that forgets WHERE tenant_id is the one that ends up in the incident report.

Mistake 8: Rolling Your Own Crypto

The most expensive mistake, and the one nobody admits to: hand-writing password hashing or token signing because the library "felt like overkill." It is never overkill. A custom hash with a static salt, or a homemade token scheme, is a breach with a delay timer on it.

The fix: use the battle-tested primitive — bcrypt or argon2 for passwords, a maintained library for token signing — and let a provider handle SSO/SAML. This is the half of the rule people skip: own your authorization and your tenant logic, but never the cryptography underneath it. If you want the full sweep of what attackers actually probe, run the OWASP Top 10 API security audit against your own endpoints.

SaaS Authentication Mistakes: The Production Checklist

LayerDangerous defaultDo this instead
Refresh tokensStatic, long-livedRotation + reuse detection
Token storagelocalStorageHttpOnly Secure cookies
JWT payloadRoles, IDs, limits insideOpaque sub, look up server-side
Access tokensLong-lived, irrevocableShort TTL + Redis blocklist
Reset linksNo expiry<15 min, single-use, hashed
Login errorsVerboseUniform generic response
Tenant scopeClient-supplied tenant_idSigned claims + RLS backstop
CryptoHand-rolledbcrypt/argon2 + vetted libs

None of these eight are exotic. They're the boring, known fixes that separate an auth layer you'd deploy on a Friday from one you'll be explaining to a customer's security team during an incident call. Get them in before you build the clever feature on top — "we'll harden auth later" is how "later" turns into a seven-figure line item.

Frequently Asked Questions

Anything in localStorage is readable by any JavaScript running on the page, including a compromised npm dependency or an injected XSS payload. One bad script and your session tokens are exfiltrated. Store tokens in HttpOnly, Secure, SameSite=Strict cookies so client-side JavaScript can never read them, which neutralizes the most common token-theft path.

Keep access tokens short — roughly 5 to 15 minutes — and pair them with refresh token rotation so users stay logged in without you issuing long-lived bearer tokens. A short access token limits the blast radius if one leaks, and for instant logout or password change you back it with a Redis blocklist keyed on the token ID so you can revoke before it naturally expires.

Build the flows, never the crypto. Use a vetted library for password hashing (bcrypt or argon2) and token signing, and lean on a provider for SSO/SAML. Rolling your own hashing or session crypto is how you end up in a breach post-mortem — stolen credentials are the initial action in about 24% of breaches (Verizon DBIR 2024). Own your authorization and tenant logic; outsource the dangerous primitives.

Return identical responses whether or not the account exists. Both login failures and password-reset requests should say something generic like 'If an account matches that address, we've sent a link.' Keep timing consistent too, since a fast 'no such user' versus a slow password check is its own side channel. The goal is that an attacker learns nothing about which emails are registered.

Broken Object-Level Authorization (BOLA) is when an API trusts a client-supplied identifier — like a tenant_id in a header — to scope data. Change that value in the browser dev tools and you read another tenant's records. The fix is to derive tenant scope only from signed server-side token claims, never from client input, and ideally enforce it in the database with row-level security as a backstop.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio