ASVS Level 1 Password Security in Node.js and Go

Series OWASP ASVS 5.0 Level 1 Part 7/8 All parts

ASVS chapter V6 Authentication has thirteen Level 1 requirements, more than any other chapter in the standard. Eight of them are about passwords, and they say something that surprises most developers the first time they read it: stop making the rules harder. No upper case requirement, no symbol requirement, no maximum of twelve characters, no blocking paste.

The rules that replace them are shorter and they do more work. A minimum length, a check against the passwords everybody already uses, and a promise that you compare what the user typed rather than a cleaned-up version of it.

This part covers those eight. Part 8 covers the other five, which are about rate limiting, default accounts, and account recovery.

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, usually one line apart.

Conceptual Overview

Composition rules were designed for the wrong attacker. “One upper case, one number, one symbol” assumes an attacker guessing character by character, so forcing variety in each position multiplies the work. Real attacks do not work that way. An attacker takes a list of passwords that leaked from somewhere else and tries them, in order of popularity, against your login form. Against that attacker, Password1! is not stronger than password, because it is already on the list.

Composition rules make passwords worse in practice. Told to add a capital and a number, people capitalise the first letter and put a 1 at the end. The rule does not add unpredictability, it just tells the attacker where to look.

Length is the rule worth keeping. ASVS asks for a minimum of 8 characters at Level 1 and recommends 15. A four-word passphrase such as correct horse battery staple is easy to remember, easy to type, and long enough that guessing it is hopeless. The composition rules that a passphrase fails are exactly the rules this chapter removes.

A blocklist is what actually stops the common attack. If you check every new password against the few thousand most popular ones, the credential stuffing attack described above stops finding matches on your site. That single check does more than every composition rule combined.

Whatever you do to the password before hashing, you do to the attacker’s guess too. Lowercasing the input at registration and at login means both sides match, so the login still works and nobody notices. What has happened is that you quietly threw away part of the password. The same is true of trimming spaces and of truncating to a maximum length.

Password managers are on your side. A user with a manager has a different, long, random password for every site. Blocking paste, setting autocomplete="off", or using type="text" breaks the tool that produces the strongest passwords your application will ever see, in exchange for no security at all.

One note on scope. ASVS puts the requirement to store passwords with a proper password hashing function at Level 2 (V11.4.2), not Level 1, so none of the eight requirements below is about hashing. The code here uses Argon2id anyway, because writing a registration endpoint that stores plaintext would be indefensible whatever a level says. If you want the background on that choice, see Password Hashing in Golang with Bcrypt and Argon2 and Various Types of Hashes Cryptography in NodeJS.

Prerequisites

Step 1: Set Up a Scratch Project

Every control below is a pair of servers named -bad and -good, both listening on port 3000. Run one at a time and use Ctrl+C between them. Users live in an in-memory map, so restarting a server empties it.

mkdir -p ~/asvs-v6/node
cd ~/asvs-v6/node
npm init -y
npm pkg set type=module
npm install fastify argon2

Each program is one file, started with node <name>.mjs.

mkdir -p ~/asvs-v6/go/cmd
cd ~/asvs-v6/go
go mod init asvs-v6
go get github.com/gofiber/fiber/v3 github.com/alexedwards/argon2id

Each program lives in its own folder under cmd/, started with go run ./cmd/<name>.

Step 4 needs a list of common passwords. Build it now, from the SecLists collection, which is the list most tools use:

cd ~/asvs-v6/node   # or ~/asvs-v6/go
curl -sfL -o top-100k.txt \
  https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/xato-net-10-million-passwords-100000.txt
awk 'length($0) >= 8' top-100k.txt | head -3000 > common-3000.txt
wc -l common-3000.txt
head -3 common-3000.txt
3000 common-3000.txt
password
12345678
123456789

The file is sorted by how often each password appears in real breaches, so filtering to entries of at least 8 characters and taking the first 3000 gives you the top 3000 that could survive your own minimum length. That phrase, “which match the application’s password policy”, is in the requirement for a reason: there is no point spending list entries on 123456 when your minimum length already rejects it.

Step 2: Make Length the Only Rule

V6.2.1 Verify that user set passwords are at least 8 characters in length although a minimum of 15 characters is strongly recommended.

V6.2.5 Verify that passwords of any composition can be used, without rules limiting the type of characters permitted. There must be no requirement for a minimum number of upper or lower case characters, numbers, or special characters.

These two are one control: a length check, and nothing else. The broken version is the regular expression that appears in almost every registration form on the internet.

Create pw-bad.mjs:

import Fastify from 'fastify'
import argon2 from 'argon2'

const users = new Map()
const app = Fastify()

app.post('/register', async (req, reply) => {
  const { email, password } = req.body

  if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,12}$/.test(password)) {
    return reply.code(400).send({ error: 'need 8 to 12 characters with upper, lower, digit, and symbol' })
  }

  users.set(email, await argon2.hash(password))
  return { registered: email }
})

await app.listen({ port: 3000 })

pw-good.mjs replaces the whole expression with a length range:

import Fastify from 'fastify'
import argon2 from 'argon2'

const users = new Map()
const app = Fastify()

app.post('/register', async (req, reply) => {
  const { email, password } = req.body
  const length = [...password].length

  if (length < 8 || length > 128) {
    return reply.code(400).send({ error: 'password must be 8 to 128 characters' })
  }

  users.set(email, await argon2.hash(password))
  return { registered: email }
})

await app.listen({ port: 3000 })

Register a long passphrase and a short password that satisfies every composition rule:

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/register
{"error":"need 8 to 12 characters with upper, lower, digit, and symbol"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"Passw0rd!"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"Passw0rd!"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}

The broken server refuses the strong passphrase and accepts Passw0rd!, which is on every guessing list ever published. That is the whole argument against composition rules in two requests.

Note [...password].length rather than password.length. JavaScript strings count UTF-16 code units, so a password containing an emoji or many non-Latin characters counts higher than the number of characters the user typed. Spreading the string into an array counts code points, which is much closer to what a person sees.

Create cmd/pw-bad/main.go. Go’s regular expression engine has no lookahead, so composition rules have to be written out, which is a fair illustration of how much code these rules cost:

package main

import (
	"log"
	"strings"
	"unicode"

	"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 meetsComposition(pw string) bool {
	if len(pw) < 8 || len(pw) > 12 {
		return false
	}
	var lower, upper, digit, symbol bool
	for _, r := range pw {
		switch {
		case unicode.IsLower(r):
			lower = true
		case unicode.IsUpper(r):
			upper = true
		case unicode.IsDigit(r):
			digit = true
		case strings.ContainsRune("!@#$%^&*", r):
			symbol = true
		}
	}
	return lower && upper && digit && symbol
}

func main() {
	app := fiber.New()

	app.Post("/register", 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"})
		}

		if !meetsComposition(in.Password) {
			return c.Status(400).JSON(fiber.Map{"error": "need 8 to 12 characters with upper, lower, digit, and symbol"})
		}

		hash, err := argon2id.CreateHash(in.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/pw-good/main.go deletes the function and checks a length range:

package main

import (
	"log"
	"unicode/utf8"

	"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() {
	app := fiber.New()

	app.Post("/register", 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"})
		}
		length := utf8.RuneCountInString(in.Password)

		if length < 8 || length > 128 {
			return c.Status(400).JSON(fiber.Map{"error": "password must be 8 to 128 characters"})
		}

		hash, err := argon2id.CreateHash(in.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

Register a long passphrase and a short password that satisfies every composition rule:

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/register
{"error":"need 8 to 12 characters with upper, lower, digit, and symbol"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"Passw0rd!"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"Passw0rd!"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}

The broken server refuses the strong passphrase and accepts Passw0rd!, which is on every guessing list ever published. That is the whole argument against composition rules in two requests.

Note utf8.RuneCountInString rather than len. Go strings are bytes, so a password of eight non-Latin characters can be twenty-four bytes long, and len would count those bytes rather than the characters the user typed.

The upper bound of 128 is not a composition rule, it is a denial of service guard: Argon2id is deliberately slow, and hashing a 10 MB “password” on every login attempt is a way to burn your CPU. Set it high enough that no real passphrase reaches it.

Step 3: Compare What the User Actually Typed

V6.2.8 Verify that the application verifies the user’s password exactly as received from the user, without any modifications such as truncation or case transformation.

This requirement exists because tidying up input is a reflex. You trim whitespace on every other form field, so you trim it here too, and while you are there you lowercase it so people are not locked out by caps lock, and you cut it to the length of the database column. The application still works perfectly, which is why nobody catches it.

Create exact-bad.mjs:

import Fastify from 'fastify'
import argon2 from 'argon2'

const users = new Map()
const app = Fastify()

const clean = (password) => password.trim().toLowerCase().slice(0, 20)

app.post('/register', async (req) => {
  const { email, password } = req.body
  users.set(email, await argon2.hash(clean(password)))
  return { registered: email }
})

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const stored = users.get(email)
  if (!stored || !(await argon2.verify(stored, clean(password)))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }
  return { authenticated: email }
})

await app.listen({ port: 3000 })

exact-good.mjs deletes the clean function and passes password straight through:

import Fastify from 'fastify'
import argon2 from 'argon2'

const users = new Map()
const app = Fastify()

app.post('/register', async (req) => {
  const { email, password } = req.body
  users.set(email, await argon2.hash(password))
  return { registered: email }
})

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 })

Create cmd/exact-bad/main.go:

package main

import (
	"log"
	"strings"

	"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 clean(password string) string {
	password = strings.ToLower(strings.TrimSpace(password))
	if len(password) > 20 {
		password = password[:20]
	}
	return password
}

func main() {
	app := fiber.New()

	app.Post("/register", 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"})
		}
		hash, err := argon2id.CreateHash(clean(in.Password), argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	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(clean(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/exact-good/main.go deletes the clean function and passes in.Password straight through:

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() {
	app := fiber.New()

	app.Post("/register", 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"})
		}
		hash, err := argon2id.CreateHash(in.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	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"))
}

Register with one passphrase, then log in with a completely different string that happens to survive the cleaning:

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"Correct Horse Battery Staple"}' \
    http://localhost:3000/register
{"registered":"[email protected]"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"CORRECT HORSE BATTERY STAPLE IS WRONG"}' \
    http://localhost:3000/login
{"authenticated":"[email protected]"}

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"CORRECT HORSE BATTERY STAPLE IS WRONG"}' \
    http://localhost:3000/login
{"error":"invalid credentials"}

The broken server authenticated a login with the wrong case and seventeen extra characters, because clean reduced both strings to correct horse batter before hashing. Every user of that application has a password that is at most 20 lower case characters long, whatever they think they chose.

The most common real version of this bug is not a clean function you can see. It is bcrypt, which ignores everything after the first 72 bytes of a password. If you use bcrypt, either reject passwords longer than 72 bytes or pre-hash them, and say which one you did in your documentation. Argon2id has no such limit, which is one reason the code here uses it.

Step 4: Reject the Passwords Everybody Else Uses

V6.2.4 Verify that passwords submitted during account registration or password change are checked against an available set of, at least, the top 3000 passwords which match the application’s password policy, e.g. minimum length.

This is the requirement that stops the attack the others only discourage. Read the wording carefully: the check applies at registration and at password change. A blocklist that is only wired into the signup form leaves a hole the width of the reset flow.

Create common-bad.mjs, which enforces length and stops there:

import Fastify from 'fastify'
import argon2 from 'argon2'

const users = new Map()
const app = Fastify()

app.post('/register', async (req, reply) => {
  const { email, password } = req.body

  if ([...password].length < 8) {
    return reply.code(400).send({ error: 'password must be at least 8 characters' })
  }

  users.set(email, await argon2.hash(password))
  return { registered: email }
})

await app.listen({ port: 3000 })

common-good.mjs loads the list into a Set at startup and adds one lookup:

import Fastify from 'fastify'
import argon2 from 'argon2'
import { readFileSync } from 'node:fs'

const COMMON = new Set(readFileSync('common-3000.txt', 'utf8').split('\n').filter(Boolean))

const users = new Map()
const app = Fastify()

app.post('/register', async (req, reply) => {
  const { email, password } = req.body

  if ([...password].length < 8) {
    return reply.code(400).send({ error: 'password must be at least 8 characters' })
  }
  if (COMMON.has(password.toLowerCase())) {
    return reply.code(400).send({ error: 'this password is one of the most common in use, please choose another' })
  }

  users.set(email, await argon2.hash(password))
  return { registered: email }
})

await app.listen({ port: 3000 })

Create cmd/common-bad/main.go, which enforces length and stops there:

package main

import (
	"log"
	"unicode/utf8"

	"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() {
	app := fiber.New()

	app.Post("/register", 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"})
		}

		if utf8.RuneCountInString(in.Password) < 8 {
			return c.Status(400).JSON(fiber.Map{"error": "password must be at least 8 characters"})
		}

		hash, err := argon2id.CreateHash(in.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/common-good/main.go loads the list into a map at startup and adds one lookup:

package main

import (
	"log"
	"os"
	"strings"
	"unicode/utf8"

	"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{}
var common = loadCommon("common-3000.txt")

func loadCommon(path string) map[string]struct{} {
	body, err := os.ReadFile(path)
	if err != nil {
		log.Fatal(err)
	}
	set := map[string]struct{}{}
	for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
		set[line] = struct{}{}
	}
	return set
}

func main() {
	app := fiber.New()

	app.Post("/register", 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"})
		}

		if utf8.RuneCountInString(in.Password) < 8 {
			return c.Status(400).JSON(fiber.Map{"error": "password must be at least 8 characters"})
		}
		if _, found := common[strings.ToLower(in.Password)]; found {
			return c.Status(400).JSON(fiber.Map{"error": "this password is one of the most common in use, please choose another"})
		}

		hash, err := argon2id.CreateHash(in.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"registered": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

Try two passwords from the list and one that is not:

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"password123"}' http://localhost:3000/register
{"registered":"[email protected]"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"qwerty123"}' http://localhost:3000/register
{"registered":"[email protected]"}

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"password123"}' http://localhost:3000/register
{"error":"this password is one of the most common in use, please choose another"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"qwerty123"}' http://localhost:3000/register
{"error":"this password is one of the most common in use, please choose another"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' http://localhost:3000/register
{"registered":"[email protected]"}

The lookup lowercases the password, and that does not contradict Step 3. Step 3 is about how you verify a password, where every byte matters. This is a check against a list of known-bad strings, and Password123 is exactly as compromised as password123, so matching case-insensitively catches more of them. Store the hash of what the user actually typed.

Three thousand entries is about 30 KB in memory, so a Set or map is fine. To go further, the Have I Been Pwned range API checks a password against hundreds of millions of breached entries without sending it anywhere: you send the first five characters of its SHA-1 hash and compare the returned hashes locally.

Step 5: Let Users Change Their Own Password, With the Old One

V6.2.2 Verify that users can change their password.

V6.2.3 Verify that password change functionality requires the user’s current and new password.

The first of these two is a feature, not a control: if changing a password means emailing support, then a user who suspects their password has leaked cannot do anything about it today. The second is what makes the feature safe.

Asking for the current password protects against someone who has your session but not your password. That is not a rare situation. It is an unlocked laptop, a shared computer where somebody did not sign out, or a stolen session cookie.

Create change-bad.mjs, seeded with one user so you have something to attack:

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('/change-password', async (req, reply) => {
  const { email, newPassword } = req.body
  const stored = users.get(email)
  if (!stored) {
    return reply.code(404).send({ error: 'no such user' })
  }

  users.set(email, await argon2.hash(newPassword))
  return { changed: true }
})

await app.listen({ port: 3000 })

change-good.mjs verifies the current password instead of merely checking that the account exists:

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('/change-password', async (req, reply) => {
  const { email, currentPassword = '', newPassword } = req.body
  const stored = users.get(email)
  if (!stored || !(await argon2.verify(stored, currentPassword))) {
    return reply.code(401).send({ error: 'current password is wrong' })
  }

  users.set(email, await argon2.hash(newPassword))
  return { changed: true }
})

await app.listen({ port: 3000 })

Create cmd/change-bad/main.go, seeded with one user so you have something to attack:

package main

import (
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type changeRequest struct {
	Email           string `json:"email"`
	CurrentPassword string `json:"currentPassword"`
	NewPassword     string `json:"newPassword"`
}

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("/change-password", func(c fiber.Ctx) error {
		var in changeRequest
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}
		if _, found := users[in.Email]; !found {
			return c.Status(404).JSON(fiber.Map{"error": "no such user"})
		}

		hash, err := argon2id.CreateHash(in.NewPassword, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"changed": true})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/change-good/main.go verifies the current password instead of merely checking that the account exists:

package main

import (
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type changeRequest struct {
	Email           string `json:"email"`
	CurrentPassword string `json:"currentPassword"`
	NewPassword     string `json:"newPassword"`
}

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("/change-password", func(c fiber.Ctx) error {
		var in changeRequest
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}
		match, err := argon2id.ComparePasswordAndHash(in.CurrentPassword, users[in.Email])
		if err != nil || !match {
			return c.Status(401).JSON(fiber.Map{"error": "current password is wrong"})
		}

		hash, err := argon2id.CreateHash(in.NewPassword, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		users[in.Email] = hash
		return c.JSON(fiber.Map{"changed": true})
	})

	log.Fatal(app.Listen(":3000"))
}

Play the attacker who has a session but not the password:

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","newPassword":"attacker chosen password"}' \
    http://localhost:3000/change-password
{"changed":true}

$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","newPassword":"attacker chosen password"}' \
    http://localhost:3000/change-password
{"error":"current password is wrong"}
$ curl -s -H 'content-type: application/json' \
    -d '{"email":"[email protected]","currentPassword":"correct horse battery staple","newPassword":"a new passphrase i picked"}' \
    http://localhost:3000/change-password
{"changed":true}

Two things about the real version. The email here comes from the request body only because the scratch server has no sessions: in your application it must come from the signed-in session, or you have handed anyone the ability to change anyone else’s password. And a successful change should invalidate the user’s other sessions and send a notification email.

The new password goes through the same checks as registration, which means the length rule from Step 2 and the blocklist from Step 4. Put those checks in one function that both routes call.

Step 6: Stop Fighting the Login Form

V6.2.6 Verify that password input fields use type=password to mask the entry. Applications may allow the user to temporarily view the entire masked password, or the last typed character of the password.

V6.2.7 Verify that “paste” functionality, browser password helpers, and external password managers are permitted.

This step is the same for both stacks, because it is HTML. Save login-bad.html:

<!doctype html>
<meta charset="utf-8">
<title>Sign in</title>
<form method="post" action="/login">
  <label>Email <input name="email" type="email" autocomplete="off"></label>
  <label>Password
    <input name="password" type="text" autocomplete="off"
           onpaste="return false" oncopy="return false" ondrop="return false">
  </label>
  <button>Sign in</button>
</form>

Then login-good.html:

<!doctype html>
<meta charset="utf-8">
<title>Sign in</title>
<form method="post" action="/login">
  <label>Email <input name="email" type="email" autocomplete="username"></label>
  <label>Password
    <input id="password" name="password" type="password" autocomplete="current-password">
  </label>
  <label>
    <input type="checkbox"
           onchange="document.getElementById('password').type = this.checked ? 'text' : 'password'">
    Show password
  </label>
  <button>Sign in</button>
</form>

Serve the two files and open them, then try to paste into the password field:

$ python3 -m http.server 4100
Serving HTTP on 0.0.0.0 port 4100 (http://0.0.0.0:4100/) ...

In the broken form the password is visible to anyone standing behind you, autocomplete="off" tells the browser not to offer a saved password, and the paste is silently swallowed. Nothing arrives in the field. In the fixed form the characters are masked, the paste lands, and the browser offers to fill and to save.

Three details are worth naming. autocomplete="username" on the email field and autocomplete="current-password" on the password field tell a password manager which pair belongs together. Use new-password on registration and change forms. The checkbox is the reveal that V6.2.6 explicitly allows, and it beats turning masking off. And there is no paste handler at all, because the right amount of code for blocking paste is none.

Common Mistakes and Troubleshooting

Enforcing the rules in the browser only. A registration form with minlength="8" and no server check is decoration. Every rule in this article has to run on the server, because that is the only place an attacker cannot skip.

Wiring the blocklist into signup but not into password change or reset. The requirement names both. A user who cannot register with password123 will happily set it five minutes later on the reset page.

Setting a maximum length that a person could hit. A limit of 20 or 32 characters is a truncation rule wearing a different hat, and it breaks generated passwords. Use 128 or more.

Telling the user which part was wrong on login. “No account with that email” versus “wrong password” hands an attacker a list of your users. Return one message for both.

Reusing the length check but forgetting the blocklist inside admin tools. An internal “set this user’s password” screen is a registration endpoint with a different name.

Assuming Argon2id defaults are free. argon2id.DefaultParams and the argon2 package defaults use 64 MB of memory per hash by design. That is the point of the algorithm, but it means a login endpoint has a concurrency limit, which is one more reason to rate limit it. That is Part 8.

Best Practices

Put every password rule in one function. Registration, change, reset, and any admin tool call the same validatePassword and get the same answer. Rules that live in three places drift in three directions.

Recommend 15 characters even though 8 passes. The requirement says 8 is the floor and 15 is strongly recommended. Set the enforced minimum where your users will accept it and word the help text around a passphrase.

Show a strength meter that reflects the real rules. A meter that rewards symbols teaches the wrong lesson. One based on length and on whether the password is in your blocklist tells the user something true.

Refresh the blocklist on a schedule. New breaches change which passwords are popular. Rebuild common-3000.txt from the upstream list once or twice a year and commit it, so the file in your repository is the one you tested against.

Send a notification when a password changes. It costs one email and it is how users find out about an account takeover while they can still do something about it.

Offer a way past passwords entirely. Passkeys and single sign-on remove this whole chapter for the users who take them. ASVS covers those separately, but every user who stops having a password on your site is one fewer password to protect.

Conclusion

You have closed eight of the thirteen Level 1 requirements in V6. Passwords are judged by length rather than by shape, the few thousand most common ones are refused, what the user typed is what gets hashed, users can change their own password by proving they know the current one, and the login form works with the password manager instead of against it.

The change that matters most is the one that is hardest to argue for in a meeting: deleting the composition rules. Bring the two curl commands from Step 2 with you. A server that rejects correct horse battery staple and accepts Passw0rd! makes the case faster than any explanation.

Mark V6.2.1 through V6.2.8 as passed in your own record.

Part 8 finishes the chapter with the other five requirements: documenting your anti-automation controls, rate limiting the login endpoint, removing default accounts, making activation codes expire, and deleting secret questions.

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.

All tutorials →

Latest Tutorials

Support this site