Passwords
Password authentication is the simplest authentication method. The user defines their password when creating the account and proves their identity by entering it.
I think it's reasonable to restrict passwords to printable ASCII characters and to reject passwords starting or ending with a space. Even outside of the U.S., it's uncommon to use non-latin characters in passwords and these contraints can catch potential input errors. Never silently modify or sanitize user input. Instead, reject the input and clearly inform the user what went wrong. The maximum password size should be set at around 50 to 100 characters to accomodate for both randomly-generated passwords and passphrases (combination of words).
I would set the minimum password length to either 8 or 10. However, I would avoid placing further complexity requirements. Instead, compare passwords against known data breaches using services like the Haveibeenpwned API. This catches commonly used passwords and prevents users from creating vulnerable accounts.
User passwords must be hashed before storage. Hasing, unlike encryption, is a one-way transaformation that can't be reversed. This ensures that user passwords aren't directly exposed when the database is breached. This is especially important as users often re-use passwords across multiple websites. The password is verified by hashing the provided input and comparing it with the stored hash. This comparison should be performed using a constant-time operation to mitigate timing attacks. However, user passwords are often weak. Even a random 8-character password is easily crackable by a computer. As such, use a strong hash algorithm designed for passwords. These hash algorithms use a significant amount of compute resources and drastically slows down brute-force attacks.
The hash must be salted. A salt is random nonce unique to each hash that gets combined with the password during hashing. This salting process ensures that even identical passwords produce unique hashes, preventing attackers from pre-computing hashes for commonly-used passwords (rainbow tables). I recommend generating at least 16 random bytes with a cryptographically-secure random source to use as the salt. The salt is not a secret. You do not need to encrypt it before storage. Peppering, on the other hand, introduces a secret key called a pepper into the hashing process that is shared across all hashes. However, this is only effective if the pepper is stored separately from the hashes in a secure location. If you need peppering, I would avoid building a custom solution on top of a hashing algorithm and instead use one that supports it natively.
If you allow all Unicode characters in passwords, normalize the text using NFC before passing it to the hashing algorithm to ensure consistent representation.
Some commonly used password hashing algorithms are Argon2, Bcrypt, and Scrypt. Bcrypt is the oldest and most-widely used option, but I recommend using Argon2. Specifically, Argon2id with at least 16MiB of memory, 3 iterations, and 1 degree of parallelism. Do not confuse Argon2id with Argon2i and Argon2d, which are all different variations of Argon2. Increase the memory size to make the hash stronger but I would avoid further adjusting the iteration and parallelism parameter. Overall, Argon2 provides slightly better protection over Bcrypt with fewer footguns. For more detailed information about the algorithms, see the Argon2 and Bcrypt page. I've also published a detailed comparison of the 2 algorithms on my blog: Is Argon2 actually better than Bcrypt?.
Note that password hashing is CPU-intensive and can fully occupy a CPU thread while running. As a result, using a slower algorithm reduces the number of passwords your server can handle. If you don't control the number of concurrent hashing operations, it may exhaust all available compute resouces and degrade the performance of your entire application. You may also run out of memory if you use a memory-intensive hashing algorithm such as Argon2. In a single-threaded environment like JavaScript, ensure that hashing is offloaded to a separate process and properly queued. In multi-threaded environments, limit the number of concurrent hashing operations using a mutex or semaphore.
All endpoints that perform password hashing must enforce strict rate limiting to prevent both resource-exhausting denial-of-service (DoS) attacks and brute-force credential guessing. A rate limit of 1 attempt per minute per user is a good starting point. For example, use a token bucket algorithm with a maximum token capacity of 5 and a refill rate of 1 token per minute. This caps an attacker at roughly 500,000 attempts per year and is generally insufficient to crack an average password. I would avoid implementing account lockouts or exponentational throttling as these can be easily abused to indefinitely lock out legitimate users. I would also avoid strict IP-based rate limits as these can accidentally block legitimate users on shared networks and is easily bypassed by attackers using proxy networks.
Ultimately, security is a shared responsibility, and we have to assume that users are choosing decently strong, unique passwords. Hashing and strict rate limiting are vital but they can only do so much to compensate for inherently weak credentials.
Finally, for a password sign-in flow, the user needs to enter an identifier (e.g., a user ID or username). It is commonly recommended to return the same error message whether the account does not exist or the password is incorrect. This is intended to prevent user enumeration, an attack in which a malicious actor can infer valid user identifiers and use them to accelerate further attacks. However, I would not fully recommend this approach. First, preventing user enumeration entirely is difficult in practice. Because password hashing is really slow, differences in response time can be used to infer whether a user identifier exists. While you can hash passwords regardless of whether the user exists, this complicates rate limiting and still does not fully eliminate timing-based inference attacks. You also have to patch other related flows, such as sign-up and password reset, to avoid introducing similar issues. More importantly, this approach can significantly degrade the user experience. It’s unclear to the user whether they have simply mistyped their credentials or are attempting to access an account that does not exist. In many cases, it's better to provide more explicit error messages. If the email address needs to be protected, use an opaque identifier such as a user ID or username instead.
