# Sign-in and sessions: where AI-generated auth goes wrong

> Tokens in localStorage or cookies, sessions that never expire, leaky password resets, and permission checks that only exist in the UI.

Published: 2025-09-06 · Source: https://subxcat.com/articles/auth-pitfalls · Author: SUBXCAT

The login form is the first thing an assistant builds in any app, and it always looks convincing. Email field, password field, button, redirect to the dashboard. The problems live behind the form: where the token is stored, how long it lives, what happens on password reset, and who on the server checks that the person is allowed to do what they're asking.

## Token in localStorage or in a cookie

Assistants almost always put the token in `localStorage`. It's the easiest option and works immediately. The cost is that any script on the page can read it. One XSS bug or one compromised analytics library, and the token is in someone else's hands, used from someone else's machine.

A cookie with `HttpOnly`, `Secure` and `SameSite=Lax` can't be read from JavaScript, so XSS can't steal it. In exchange you get a different concern, CSRF: the browser sends the cookie on its own, so state-changing requests need protecting. `SameSite` covers most cases; a CSRF token covers the rest.

There's no universal answer, but for a typical web app with its own backend, cookies are usually the safer choice. If you stay with `localStorage`, at least keep the token short-lived and take XSS seriously.

## Sessions that never expire

A frequent find: a JWT with no `exp` claim, or one that expires in a year. That token is a permanent pass. Logging out removes it from the browser, but the server keeps accepting it if anyone kept a copy.

What you need:

- A short-lived access token (minutes to hours) and a separate refresh token you can revoke.
- Server-side session storage or a revocation list, so "log out" and "log out everywhere" actually do something.
- All sessions revoked when the password or email changes.

## Password reset

This is where mistakes cost the most, because a reset is, by definition, a way in without a password.

- **The link is reusable and never expires.** A six-month-old email in someone else's inbox should be useless. Expiry in minutes or hours, one use only.
- **The token is guessable.** A user ID, a timestamp or a short number instead of a cryptographically random string.
- **The link is built from the `Host` header.** An attacker requests a reset for your email with a forged header, and you receive a genuine email linking to their domain. Take the site URL from config, never from the request.
- **The response reveals whether the email exists.** "User not found" turns the form into a tool for checking other people's addresses. Answer the same way every time: "if that address exists, we've sent an email".
- **Old sessions survive the reset.** Someone resets their password because they suspect a break-in, and whoever broke in stays logged in.

## Checks that only exist in the UI

The most common and most dangerous mistake: permissions are enforced by the interface. The Admin button is hidden unless the profile has an `isAdmin` flag, and the flag lives in `localStorage` or arrives from the client in the request body.

Signs of it:

- Role or plan is read from something the browser sent.
- Middleware protects pages but not the API. In Next.js it's easy to configure `matcher` so that `/api` isn't covered.
- The server checks that someone is logged in, but not that the record belongs to them.

The rule is simple: every server handler works out who is calling from the session, not from a field in the request, and decides on its own whether they're allowed. The UI only hides buttons so people don't get confused.

## Small things that aren't small later

Changing the email without re-entering the password. Linking a social login to an existing account by an unverified address. Email verification you can skip by navigating to another page. Each is a line of code, and each one opens someone else's account.

If an assistant wrote your auth and nobody has read it since, start there. SUBXCAT reviews exactly these spots and fixes them without rewriting your whole sign-in.
