Part 7 covered the eight Level 1 requirements in ASVS chapter V6 Authentication that are about the password itself. This part covers the other five, and they are about everything around it: how many guesses an attacker gets, what accounts exist before your first user signs up, how somebody who has never logged in gets their first credential, and what happens when a user forgets theirs.
Four of the five are code. The first one is a Markdown file, and it is not filler. V6.3.1 says your anti-automation controls must be implemented according to your documentation, which means there has to be a document to check them against. Writing it first is the only order that makes sense.
Everything below exists in both Node.js with Fastify and Go with Fiber. Pick your stack once and the whole article follows it. Each control is a broken version next to a fixed version.
Conceptual Overview
Credential stuffing and brute force are different attacks with one defence. Brute force means many guesses against one account. Credential stuffing means one or two guesses each against thousands of accounts, using email and password pairs that leaked from a different site. The second is far more common and far more successful, because people reuse passwords. Counting failures per account catches the first. Counting requests per source address catches the second.
Locking an account is a weapon you hand to the attacker. If five wrong passwords disable an account until an administrator clears it, then anyone who knows a user’s email address can take that user offline whenever they like, forever, for free. This is why ASVS asks you to document how your controls “prevent malicious account lockout” rather than just asking you to lock accounts.
A cooldown keyed on the account and the source address avoids that trap. Failures from the attacker’s address slow the attacker down. The real user, connecting from somewhere else, never notices. Nothing is ever disabled, and the cooldown clears itself.
An initial password or activation code is a password with a shorter life. It grants full access to an account, it is often sent over email, and it is frequently the weakest secret in the system because somebody generated it with a six-digit random number. It has to be as unguessable as a real password, usable once, and dead within minutes.
Secret questions are not secrets. Your mother’s maiden name, your first school, and the street you grew up on are on your relatives’ social media profiles. A recovery mechanism built on them is a second, weaker password that the user cannot change and did not choose.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Node.js 22 or later, or Go 1.25 or later, from Build a REST API with Fastify and MySQL on Ubuntu or Build a REST API in Go with Fiber v3 on Ubuntu.
curlandjq. Install the second withsudo apt install jq.- Part 7, since the code here reuses its user store and password hashing.
- Somewhere to record what you close. Part 1 lists all 70 Level 1 requirements.
Step 1: Set Up a Scratch Project
The same layout as Part 7. Every control is a pair of servers named -bad and -good on port 3000, run one at a time.
mkdir -p ~/asvs-v6b/node/docs
cd ~/asvs-v6b/node
npm init -y
npm pkg set type=module
npm install fastify @fastify/rate-limit argon2
mkdir -p ~/asvs-v6b/go/cmd ~/asvs-v6b/go/docs
cd ~/asvs-v6b/go
go mod init asvs-v6b
go get github.com/gofiber/fiber/v3 github.com/alexedwards/argon2id
Step 2: Write Down How the Login Is Defended
V6.1.1 Verify that application documentation defines how controls such as rate limiting, anti-automation, and adaptive response, are used to defend against attacks such as credential stuffing and password brute force. The documentation must make clear how these controls are configured and prevent malicious account lockout.
This is the second of the four documentation requirements in Level 1, after V2.1.1 in Part 3. Like that one, it is not asking for a policy document written by someone who has never seen the code. It is asking for a short file, in the repository, that a reviewer can hold next to the login handler.
Write docs/authentication.md. This template describes exactly what Step 3 builds, so fill in the numbers you actually configure:
# Authentication Defenses
Last reviewed: 2026-08-23. Owner: platform team.
## What we are defending against
- Credential stuffing: an attacker replays email and password pairs leaked
from other sites against our login endpoint.
- Password brute force: an attacker guesses many passwords for one account.
- Malicious lockout: an attacker deliberately fails logins against someone
else's account in order to take that person offline.
## Controls
1. Per address request rate limit. Every route allows 20 requests per minute
from one source address, enforced by the framework's rate limit
middleware. Exceeding it returns 429.
2. Per account and address failure cooldown. Five consecutive failed logins
for the same account from the same source address start a 15 minute
cooldown for that pair. During the cooldown the endpoint returns 429 with
a Retry-After header. A successful login clears the counter.
3. Uniform failure response. A wrong password and an unknown account both
return 401 with the same body, so the endpoint does not reveal which
accounts exist.
## Why this cannot lock a user out
The cooldown is keyed on the account and the source address together, so
failures from an attacker's address do not affect the real user's address.
The cooldown expires by itself and there is no state an operator has to
clear by hand. No account is ever disabled by failed logins.
## Residual risk
An attacker spreading guesses across many source addresses can stay under
five failures per pair. We accept this at Level 1. Adaptive responses, such
as challenging unusual sign-in locations or capping failed logins per account
across all addresses, are Level 2 and are not implemented.
## Where this lives
- Handler: src/routes/login.js
- Test: test/login-rate-limit.test.js asserts that the sixth failure
returns 429 and that a different source address is unaffected.
- Reviewed at every change to the login handler, and at least once a year.
Two parts of that file do the real work. The “why this cannot lock a user out” section is what the requirement explicitly asks for, and writing it forces you to notice if your design cannot answer it. The “residual risk” section is what keeps the document honest: a page that claims complete protection is a page nobody will believe, and Level 1 does not require complete protection.
Step 3: Rate Limit Failed Logins
V6.3.1 Verify that controls to prevent attacks such as credential stuffing and password brute force are implemented according to the application’s security documentation.
Now build what the document describes. Two layers: a per address request limit from middleware, and a failure counter keyed on the account and the address together.
Create rate-bad.mjs, a login endpoint with nothing in front of it:
import Fastify from 'fastify'
import argon2 from 'argon2'
const users = new Map([['[email protected]', await argon2.hash('correct horse battery staple')]])
const app = Fastify()
app.post('/login', async (req, reply) => {
const { email, password } = req.body
const stored = users.get(email)
if (!stored || !(await argon2.verify(stored, password))) {
return reply.code(401).send({ error: 'invalid credentials' })
}
return { authenticated: email }
})
await app.listen({ port: 3000 })
rate-good.mjs adds the middleware and the counter:
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import argon2 from 'argon2'
const users = new Map([['[email protected]', await argon2.hash('correct horse battery staple')]])
const failures = new Map()
const MAX_FAILURES = 5
const COOLDOWN_MS = 15 * 60 * 1000
const app = Fastify()
await app.register(rateLimit, { max: 20, timeWindow: '1 minute' })
app.post('/login', async (req, reply) => {
const { email, password } = req.body
const key = `${email}|${req.ip}`
const blockedUntil = failures.get(key)?.until ?? 0
if (blockedUntil > Date.now()) {
const seconds = Math.ceil((blockedUntil - Date.now()) / 1000)
return reply.code(429).header('retry-after', seconds)
.send({ error: 'too many failed attempts, try again later' })
}
const stored = users.get(email)
if (!stored || !(await argon2.verify(stored, password))) {
const record = failures.get(key) ?? { count: 0, until: 0 }
record.count += 1
if (record.count >= MAX_FAILURES) {
record.count = 0
record.until = Date.now() + COOLDOWN_MS
}
failures.set(key, record)
return reply.code(401).send({ error: 'invalid credentials' })
}
failures.delete(key)
return { authenticated: email }
})
await app.listen({ port: 3000 })
Create cmd/rate-bad/main.go, a login endpoint with nothing in front of it:
package main
import (
"log"
"github.com/alexedwards/argon2id"
"github.com/gofiber/fiber/v3"
)
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
var users = map[string]string{}
func main() {
seed, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
users["[email protected]"] = seed
app := fiber.New()
app.Post("/login", func(c fiber.Ctx) error {
var in credentials
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
match, err := argon2id.ComparePasswordAndHash(in.Password, users[in.Email])
if err != nil || !match {
return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
}
return c.JSON(fiber.Map{"authenticated": in.Email})
})
log.Fatal(app.Listen(":3000"))
}
cmd/rate-good/main.go adds the middleware and the counter:
package main
import (
"log"
"strconv"
"time"
"github.com/alexedwards/argon2id"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
type attempt struct {
count int
until time.Time
}
const maxFailures = 5
const cooldown = 15 * time.Minute
var users = map[string]string{}
var failures = map[string]attempt{}
func main() {
seed, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
users["[email protected]"] = seed
app := fiber.New()
app.Use(limiter.New(limiter.Config{Max: 20, Expiration: time.Minute}))
app.Post("/login", func(c fiber.Ctx) error {
var in credentials
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
key := in.Email + "|" + c.IP()
if blocked := failures[key].until; time.Now().Before(blocked) {
seconds := int(time.Until(blocked).Seconds()) + 1
c.Set("Retry-After", strconv.Itoa(seconds))
return c.Status(429).JSON(fiber.Map{"error": "too many failed attempts, try again later"})
}
match, err := argon2id.ComparePasswordAndHash(in.Password, users[in.Email])
if err != nil || !match {
record := failures[key]
record.count++
if record.count >= maxFailures {
record.count = 0
record.until = time.Now().Add(cooldown)
}
failures[key] = record
return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
}
delete(failures, key)
return c.JSON(fiber.Map{"authenticated": in.Email})
})
log.Fatal(app.Listen(":3000"))
}
Now play both roles at once. Every address in 127.0.0.0/8 is already on your machine, so curl --interface gives you two different source addresses without any setup. The attacker guesses from 127.0.0.2, and the real user logs in from 127.0.0.3:
for i in $(seq 1 7); do
printf "attempt %s: " "$i"
curl -s --interface 127.0.0.2 -o /dev/null -w "%{http_code}\n" \
-H 'content-type: application/json' \
-d "{\"email\":\"[email protected]\",\"password\":\"guess$i\"}" \
http://localhost:3000/login
done
curl -s --interface 127.0.0.3 -H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple"}' \
http://localhost:3000/login
attempt 1: 401
attempt 2: 401
attempt 3: 401
attempt 4: 401
attempt 5: 401
attempt 6: 401
attempt 7: 401
{"authenticated":"[email protected]"}
attempt 1: 401
attempt 2: 401
attempt 3: 401
attempt 4: 401
attempt 5: 401
attempt 6: 429
attempt 7: 429
{"authenticated":"[email protected]"}
The broken server gave the attacker seven free guesses and would have given seven thousand. The fixed server stopped at five, and the real user logged in normally throughout, from a different address, while the attacker was in a cooldown. That last line is the evidence for the “why this cannot lock a user out” section of your document.
Two honest limits. The in-memory Map is per process, so with more than one instance behind a load balancer an attacker gets five guesses per instance: use Redis, or the shared storage adapter your rate limit middleware supports. And req.ip and c.IP() return the address of whatever connected, which behind a reverse proxy is the proxy. Configure your framework to trust the proxy and read X-Forwarded-For, or every user shares one counter.
Step 4: Make Initial Secrets Random, Single Use, and Short Lived
V6.4.1 Verify that system generated initial passwords or activation codes are securely randomly generated, follow the existing password policy, and expire after a short period of time or after they are initially used. These initial secrets must not be permitted to become the long term password.
Read that as four separate conditions, because broken implementations usually fail three of them at once. The classic is a six-digit code from a general purpose random number generator, stored as plain text, valid forever, and reusable.
Create invite-bad.mjs:
import Fastify from 'fastify'
import argon2 from 'argon2'
const users = new Map()
const invites = new Map()
const app = Fastify()
app.post('/invite', async (req) => {
const { email } = req.body
const code = String(Math.floor(Math.random() * 1000000)).padStart(6, '0')
invites.set(code, email)
return { email, code }
})
app.post('/activate', async (req, reply) => {
const { code, newPassword } = req.body
const email = invites.get(code)
if (!email) {
return reply.code(400).send({ error: 'unknown code' })
}
users.set(email, await argon2.hash(newPassword))
return { activated: email }
})
await app.listen({ port: 3000 })
invite-good.mjs generates the code with the cryptographic random source, stores only its SHA-256 hash, gives it an expiry, and deletes it on use:
import Fastify from 'fastify'
import argon2 from 'argon2'
import { randomBytes, createHash } from 'node:crypto'
const INVITE_TTL_MS = 15 * 60 * 1000
const digest = (code) => createHash('sha256').update(code).digest('hex')
const users = new Map()
const invites = new Map()
const app = Fastify()
app.post('/invite', async (req) => {
const { email } = req.body
const code = randomBytes(16).toString('hex')
invites.set(digest(code), { email, expires: Date.now() + INVITE_TTL_MS })
return { email, code }
})
app.post('/activate', async (req, reply) => {
const { code = '', newPassword } = req.body
const invite = invites.get(digest(code))
if (!invite || invite.expires < Date.now()) {
return reply.code(400).send({ error: 'invite is invalid or expired' })
}
invites.delete(digest(code))
users.set(invite.email, await argon2.hash(newPassword))
return { activated: invite.email }
})
await app.listen({ port: 3000 })
Math.random is not a cryptographic random source. Its output is predictable from previous outputs, and the range is a million values, which an attacker walks through in minutes. randomBytes(16) gives 128 bits from the operating system.
Create cmd/invite-bad/main.go:
package main
import (
"fmt"
"log"
"math/rand"
"github.com/alexedwards/argon2id"
"github.com/gofiber/fiber/v3"
)
type activation struct {
Email string `json:"email"`
Code string `json:"code"`
NewPassword string `json:"newPassword"`
}
var users = map[string]string{}
var invites = map[string]string{}
func main() {
app := fiber.New()
app.Post("/invite", func(c fiber.Ctx) error {
var in activation
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
code := fmt.Sprintf("%06d", rand.Intn(1000000))
invites[code] = in.Email
return c.JSON(fiber.Map{"email": in.Email, "code": code})
})
app.Post("/activate", func(c fiber.Ctx) error {
var in activation
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
email, found := invites[in.Code]
if !found {
return c.Status(400).JSON(fiber.Map{"error": "unknown code"})
}
hash, err := argon2id.CreateHash(in.NewPassword, argon2id.DefaultParams)
if err != nil {
return err
}
users[email] = hash
return c.JSON(fiber.Map{"activated": email})
})
log.Fatal(app.Listen(":3000"))
}
cmd/invite-good/main.go generates the code with crypto/rand, stores only its SHA-256 hash, gives it an expiry, and deletes it on use:
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log"
"time"
"github.com/alexedwards/argon2id"
"github.com/gofiber/fiber/v3"
)
type activation struct {
Email string `json:"email"`
Code string `json:"code"`
NewPassword string `json:"newPassword"`
}
type invite struct {
email string
expires time.Time
}
const inviteTTL = 15 * time.Minute
var users = map[string]string{}
var invites = map[string]invite{}
func digest(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func main() {
app := fiber.New()
app.Post("/invite", func(c fiber.Ctx) error {
var in activation
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return err
}
code := hex.EncodeToString(raw)
invites[digest(code)] = invite{email: in.Email, expires: time.Now().Add(inviteTTL)}
return c.JSON(fiber.Map{"email": in.Email, "code": code})
})
app.Post("/activate", func(c fiber.Ctx) error {
var in activation
if err := c.Bind().Body(&in); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
}
found, ok := invites[digest(in.Code)]
if !ok || time.Now().After(found.expires) {
return c.Status(400).JSON(fiber.Map{"error": "invite is invalid or expired"})
}
delete(invites, digest(in.Code))
hash, err := argon2id.CreateHash(in.NewPassword, argon2id.DefaultParams)
if err != nil {
return err
}
users[found.email] = hash
return c.JSON(fiber.Map{"activated": found.email})
})
log.Fatal(app.Listen(":3000"))
}
Note the import swap. math/rand produces a predictable sequence from a million possible values, which an attacker walks through in minutes. crypto/rand reads from the operating system and gives 128 bits.
Create an invite and try to use it twice:
CODE=$(curl -s -H 'content-type: application/json' \
-d '{"email":"[email protected]"}' http://localhost:3000/invite | jq -r .code)
echo "code: $CODE"
curl -s -w " [%{http_code}]\n" -H 'content-type: application/json' \
-d "{\"code\":\"$CODE\",\"newPassword\":\"first passphrase here\"}" \
http://localhost:3000/activate
curl -s -w " [%{http_code}]\n" -H 'content-type: application/json' \
-d "{\"code\":\"$CODE\",\"newPassword\":\"attacker passphrase\"}" \
http://localhost:3000/activate
code: 441630
{"activated":"[email protected]"} [200]
{"activated":"[email protected]"} [200]
code: fcaf500e10c97cb19e7d816347b2f263
{"activated":"[email protected]"} [200]
{"error":"invite is invalid or expired"} [400]
On the broken server the code still works after the account has been set up, so anyone who ever saw that email, including whoever forwarded it, can take the account over later. To watch the expiry, drop INVITE_TTL_MS (or inviteTTL) to five seconds, restart, and wait six seconds before activating:
{"error":"invite is invalid or expired"} [400]
Storing the hash rather than the code is the same reasoning as storing password hashes. Anyone who reads your database, including a backup on a laptop, gets a list of live account takeovers otherwise. And because the code is a 32 character random string, it satisfies “follows the existing password policy” without any extra work.
Step 5: Ship With No Accounts At All
V6.3.2 Verify that default user accounts (e.g., “root”, “admin”, or “sa”) are not present in the application or are disabled.
This step has no Node.js or Go version, because the account is almost never in application code. It is in a seed migration, a fixture, a Docker entrypoint, or an installer, written by whoever needed to log in on day one.
Here is the file to look for:
INSERT INTO users (email, password_hash, role)
VALUES ('[email protected]',
'$argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG',
'admin');
The password behind that hash was chosen once, written in a setup guide, and is now identical on every installation of your software. Attack tools try exactly this before they try anything else.
The fix is to delete the migration and create the first administrator with the invite flow from Step 4, run once against the running application by whoever installs it. The code is generated on the server, printed once, expires in fifteen minutes, and is destroyed when it is used, so there is nothing left for a later reader of the repository to find.
Verify in two places. First, that nothing in your setup files creates a login:
$ grep -rniE "admin|root|sa'" db/migrations/
db/migrations/0002_seed_admin.sql:2:VALUES ('[email protected]',
db/migrations/0002_seed_admin.sql:4: 'admin');
That output is the failing case. After deleting the migration, the command prints nothing. Second, that the obvious accounts do not answer:
$ for u in "[email protected]:admin" "[email protected]:admin123" \
"[email protected]:root" "[email protected]:sa"; do
printf "%s -> " "$u"
curl -s -o /dev/null -w "%{http_code}\n" -H 'content-type: application/json' \
-d "{\"email\":\"${u%%:*}\",\"password\":\"${u##*:}\"}" \
http://localhost:3000/login
done
[email protected]:admin -> 401
[email protected]:admin123 -> 401
[email protected]:root -> 401
[email protected]:sa -> 401
The same rule applies to everything your application talks to. A default database user, a message broker with guest/guest, and a monitoring dashboard with admin/admin are all the same finding.
Step 6: Delete Secret Questions and Password Hints
V6.4.2 Verify that password hints or knowledge-based authentication (so-called “secret questions”) are not present.
The only requirement in this series whose fix is a deletion. There is no compliant way to keep secret questions, so there is no fixed version to show.
Look for the columns:
ALTER TABLE users
ADD COLUMN secret_question TEXT,
ADD COLUMN secret_answer TEXT;
Anything of this shape goes: the two columns, the route that reads them, the questions in the signup form, and the branch in your support tooling that lets an agent read a user out of an account by asking one. Password hints are the same idea in a shorter form, since a hint that helps the user remember also helps anybody who reads it.
What replaces it is the machinery you already built in Step 4. A user who cannot log in asks for a recovery link, you generate a random single-use code with a short expiry, and you send it to an address or number the account already had. The user sets a new password with it, exactly like an invite. The recovery flow and the invite flow are the same code path with a different email template.
Answer the reply everyone gives at this point: what about users who lose access to their email as well? They contact support, and support verifies them some other way that is not a question with an answer on the internet. That path should be rare, deliberate, slow, and logged, which is precisely what secret questions were invented to avoid, and precisely why they got attacked.
Common Mistakes and Troubleshooting
Rate limiting the whole login route but not per account. A middleware limit of 20 requests per minute per address is not a defence against credential stuffing, which sends one attempt per account across thousands of accounts from a rotating set of addresses. Both counters are needed.
Returning a different error for an unknown account. “No user with that email” turns your login form into a tool for checking which email addresses are registered. Return the same 401 either way, and keep the timing similar by hashing a dummy password when the account does not exist.
Counting failures in process memory and running several instances. Five attempts times four instances is twenty attempts. Put the counter in Redis or in your rate limit middleware’s shared storage.
Trusting X-Forwarded-For from anyone. If your framework reads that header without a trusted proxy list, an attacker sets it to a new random value on every request and every counter keyed on the address becomes useless.
Leaving the invite endpoint open. In these examples /invite has no authorization check, because they are scratch servers. In a real application, creating an invite is an administrative action and needs the checks from V8 Authorization.
Deleting the secret question route but leaving the columns. The answers are still in your database and in every backup, and they are still personal data you now have no reason to hold.
Best Practices
Log every failed login with the account and source address. You cannot notice credential stuffing without the data, and the log line is also the evidence a reviewer will ask for.
Make the cooldown grow. Five failures gives fifteen minutes, the next five give an hour. Legitimate users almost never reach the second step, and an attacker’s cost rises quickly.
Send an email when the cooldown starts. It tells the real user that somebody is guessing their password, which is the moment a password change is most useful.
Use the same generator for every short-lived secret. Invites, password resets, email verification, and device confirmations are one function that returns a random code and stores its hash with an expiry. Written four times, three of them will be wrong.
Test the rate limit in CI. A test that asserts the sixth failure returns 429 catches the day somebody moves the middleware or refactors the handler. The document you wrote in Step 2 names that test for a reason.
Put the review date in the document. An anti-automation policy that was accurate two years ago is a liability, because it is the thing your auditor will read instead of the code.
Conclusion
That completes V6 Authentication, the largest chapter in Level 1. Across both parts you have removed the composition rules, blocked the passwords everybody already uses, kept the user’s input intact, given people a working change-password flow, put a documented cooldown in front of the login endpoint, removed default accounts, made initial secrets short lived and single use, and deleted secret questions.
The command worth keeping from this part is the two-address test in Step 3. It answers the question that stops most teams from rate limiting at all, which is whether the control can be turned against their own users.
Mark V6.1.1, V6.3.1, V6.3.2, V6.4.1, and V6.4.2 as passed in your own record, and commit docs/authentication.md alongside them.
The next part covers V7 Session Management and its six Level 1 requirements: what a session token is, where it should live, and when it has to stop working.
The OWASP Application Security Verification Standard is licensed under Creative Commons Attribution-ShareAlike 4.0, which is what permits the requirement text to be reproduced here.