Writing Password Storage and Login Code With LLMs in November 2026: Use a Vetted Hashing Library, Rate-Limit Every Attempt, and Test the Failure Paths

Ask an LLM to "add a signup and login endpoint" and you will get something that runs on the first try. You can create an account, log in, and see the dashboard. That is exactly the problem: login code that works for the happy path can still store passwords in a way that turns one database leak into thousands of compromised accounts, or let a script guess passwords all night without anyone noticing.

This guide is about getting a model to write password storage and login code you can actually ship. It covers what to decide before you prompt, the rules to spell out, what to look for in the code that comes back, and how to test the parts that only matter when something goes wrong. The examples use Python, but the checklist applies to any language.

First question: should you be writing this at all?

The safest password code is code you do not write. Before you ask a model for a login system, check whether one of these fits:

  • Your framework's built-in auth. Django, Rails (with has_secure_password or Devise), Laravel, ASP.NET Core Identity, and Phoenix's generated auth all handle hashing, sessions, and resets. They have been reviewed by far more people than your weekend project will be.
  • A hosted identity provider. If you do not want to own passwords at all, an external provider moves storage, resets, and multi-factor login off your plate.
  • Passkeys or "sign in with" options. If your users will accept them, there is no password for you to leak.

If you still need custom code, for example a small internal tool or a stack with no good auth library, keep going. Tell the model up front which framework you use, so it can lean on what already exists instead of reinventing it.

A heavy round bank vault door

Nobody builds their own bank vault from scratch. Reach for a proven auth library before you hand-roll password storage. Image: Davis County Bank vault door by Ben P L from Provo, USA, CC BY-SA 2.0, via Wikimedia Commons.

Decide the rules before you prompt

Models fill gaps with whatever was common in their training data, and a lot of old tutorials hash passwords with MD5 or a single round of SHA-256. Do not leave room for that. Write down these decisions and paste them into the prompt:

  1. The hashing algorithm and library. Use a slow, salted password hashing function: Argon2id first, scrypt or bcrypt as reasonable alternatives, PBKDF2 when you need a FIPS-approved option. Name the exact library, such as argon2-cffi for Python. The OWASP Password Storage Cheat Sheet lists current recommended parameters; check it rather than trusting the model's memory of them.
  2. Password length rules. Current NIST guidance (SP 800-63B) favors length over composition rules: set a sensible minimum, allow long passwords (at least 64 characters), allow spaces and any Unicode characters, and skip "must contain a symbol" rules and forced periodic changes. Look up the current minimum for your case instead of guessing.
  3. Breached password checks. Decide whether you will reject passwords that appear in known breach lists, and how (a local list, or a k-anonymity range lookup against a breach database).
  4. Rate limiting and lockout policy. For example: at most 5 failed attempts per account per 15 minutes, plus a per-IP limit, with a growing delay rather than a permanent lock that attackers can use to lock out real users.
  5. What the user sees on failure. One generic message for wrong email and wrong password alike.

A prompt that sets the guardrails

Here is a prompt shape that works much better than "write a login endpoint":

I'm building a Flask 3 app with SQLAlchemy and PostgreSQL.
Write the password hashing helpers and the /login and /signup handlers.

Rules:
- Hash with argon2-cffi's PasswordHasher (Argon2id), default params
  unless I say otherwise. Never use MD5, SHA-1, or plain SHA-256.
- Store only the encoded hash string. No plaintext, no reversible
  encryption, no separate salt column (the encoded hash includes it).
- On successful login, call check_needs_rehash and update the stored
  hash if it returns True.
- Same error message and similar timing for unknown email and wrong
  password.
- Never log the password, the hash, or the full request body.
- Passwords: minimum length from a constant, max 128 chars, any
  characters allowed, no composition rules.
- Leave a clearly marked hook for rate limiting; do not invent one.

Explain any security choice you make that I did not specify.

The last line matters. It forces the model to surface its assumptions, which is where most of the problems hide.

What good output looks like

A correct core is short. With argon2-cffi, it looks roughly like this:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError

ph = PasswordHasher()  # Argon2id with library defaults

# A real hash of a random throwaway value, computed once at startup.
DUMMY_HASH = ph.hash("not-a-real-password-placeholder")

def hash_password(password: str) -> str:
    return ph.hash(password)

def verify_login(user, password: str) -> bool:
    stored = user.password_hash if user else DUMMY_HASH
    try:
        ph.verify(stored, password)
    except (VerifyMismatchError, InvalidHashError):
        return False
    if user is None:
        return False
    if ph.check_needs_rehash(user.password_hash):
        user.password_hash = ph.hash(password)
        # caller commits the session
    return True

Notice three things. Verification uses the library's own verify, not a string comparison the model wrote by hand. When the email does not exist, the code still runs one hash verification against a dummy hash, so an attacker cannot tell real accounts from fake ones by how fast the response comes back. And check_needs_rehash lets you raise the cost parameters later and upgrade users quietly as they log in.

Red flags to look for in generated code

Read every line the model gives you with this list next to you:

  • Fast hashes. hashlib.md5, hashlib.sha1, or hashlib.sha256 applied to a password, even with a salt, is wrong. These are built to be fast, which is what an attacker with a GPU wants.
  • Hand-rolled salts or comparisons. Code that generates its own salt and stores it in a second column, or compares hashes with ==, usually means the model is assembling a scheme instead of using a library. If you must compare secrets yourself, use hmac.compare_digest.
  • Different errors for different failures. "No account with that email" versus "Wrong password" tells an attacker which emails are registered. The same applies to signup and password reset pages: "If that address has an account, we sent a link" is the safe wording.
  • Passwords in logs. Look for print(request.json), debug logging of form data, or exception handlers that dump the whole request. Search the code for the word "password" and check every hit.
  • bcrypt without a length check. bcrypt only uses the first 72 bytes of input. If the model picked bcrypt, make sure it either rejects longer passwords or explains how it handles them, rather than silently ignoring the tail.
  • Invented rate limiting. An in-memory dictionary of attempt counts disappears on restart and does not work across multiple server processes. Use a shared store such as Redis or your database, or a maintained rate-limiting library.
  • Weak session handling after login. Check that the session ID is regenerated on login, cookies are HttpOnly, Secure, and have a SameSite setting, and that logout actually invalidates the server-side session.
A Dudley combination padlock

A combination lock is only as strong as the number of guesses it allows. Rate limits do the same job for a login form. Image: Dudley combination lock by Raysonho @ Open Grid Scheduler / Grid Engine, CC0, via Wikimedia Commons.

Password resets are login code too

Reset flows are where generated code most often cuts corners. State these rules explicitly:

  • Generate tokens with a cryptographically secure source, such as Python's secrets.token_urlsafe(32), never random.
  • Store only a hash of the reset token, so a database leak does not hand out working reset links.
  • Make tokens single-use and short-lived, for example 30 to 60 minutes.
  • After a successful reset, invalidate the user's other sessions and any other outstanding reset tokens.
  • Rate-limit reset requests per email and per IP so the feature cannot be used to flood someone's inbox.

Test the failure paths, not just the happy path

The model will happily write a test that signs up and logs in. That test proves very little. Ask for tests like these, and run them yourself:

  1. Stored value is not the password. After signup, read the row directly and assert the password does not appear in it and the hash starts with the expected prefix ($argon2id$ for Argon2id).
  2. Same password, different hashes. Two users with the same password must end up with different stored hashes. If they match, salting is broken.
  3. Identical error responses. Wrong password and unknown email must return the same status code and the same body.
  4. Rate limit kicks in. Send six bad attempts in a row and assert the sixth is throttled, then assert a correct password is also refused until the window passes.
  5. Long and unusual passwords. Test a 128-character password, a password with spaces, and one with non-ASCII characters such as accented letters or emoji. All should sign up and log in correctly.
  6. Rehash on login. Create a user with a hash made using lower cost parameters, log in, and assert the stored hash changed.
  7. No secrets in logs. Capture log output during a failed and a successful login and assert the test password string never appears.
  8. Reset tokens die. Use a reset token once, then try it again and assert it fails. Try an expired token and assert it fails.

A quick manual check is worth doing too: time a few hundred logins with a known email and an unknown email. The averages should be close. If unknown emails come back noticeably faster, the dummy hash step is missing.

Tune the cost, then measure

Slow hashing is the point, but it costs CPU and memory on your server. Measure how long one hash takes on your production hardware. Many teams aim for somewhere well under a second per login. If hashing is too slow, adjust the parameters within the ranges OWASP lists rather than switching to a fast hash. If it is very fast, you probably have room to raise them. Keep the chosen parameters in one place in code so the rehash-on-login step can upgrade old hashes when you change them.

A short checklist before you merge

  • Framework auth or a hosted provider considered first.
  • Argon2id, scrypt, bcrypt, or PBKDF2 via a maintained library, with documented parameters.
  • Only the encoded hash stored; rehash on login when parameters change.
  • Generic error messages and similar timing for unknown users.
  • Rate limiting backed by a shared store, per account and per IP.
  • No passwords or tokens in logs, errors, or analytics.
  • Reset tokens random, hashed, single-use, and short-lived.
  • Failure-path tests written and passing.

An LLM can write the boring parts of authentication quickly and correctly, but only if you hand it the rules instead of letting it guess. Decide the algorithm, the limits, and the error behavior yourself, make the model explain anything it adds, and judge the result by the tests that cover what happens when someone is trying to break in.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Tencent Hy4 Preview: Open 770B MoE Built for Real Work

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)