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.

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:
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.

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:
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.
Mistake 5: Password Reset Links That Never Expire
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)

This is the multi-tenant killer. Scoping a query by a tenant_id that came from a request header is an open door:
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
| Layer | Dangerous default | Do this instead |
|---|---|---|
| Refresh tokens | Static, long-lived | Rotation + reuse detection |
| Token storage | localStorage | HttpOnly Secure cookies |
| JWT payload | Roles, IDs, limits inside | Opaque sub, look up server-side |
| Access tokens | Long-lived, irrevocable | Short TTL + Redis blocklist |
| Reset links | No expiry | <15 min, single-use, hashed |
| Login errors | Verbose | Uniform generic response |
| Tenant scope | Client-supplied tenant_id | Signed claims + RLS backstop |
| Crypto | Hand-rolled | bcrypt/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.
