How to Work with Passwords with Argon2 in Go
Usernames and passwords are still one of the main forms of user authentication for most web applications. It certainly has flaws and downsides, but in my opinion, I often still choose this option over signing up with Google, Github, etc. because of the ease of storing credentials with my password manager.
When building a JSON API for an IMDB-like application as a portfolio project I wanted to build a full featured auth solution. In my opinion there is too much talk about how bad an idea it is to roll your own auth. I’m a fan of stepping into topics to better understand them oppose to backing off and claiming that only a selected few are smart enough to do so.
This article will focus on how to use the Argon2 algorithm for hashing and verifying passwords in addition to some considerations for handling passwords in your application. We round off by encrypting the output of argon2 with a secret key for more in-depth security.
Please note that this article is written by a mediocre web developer and is by no means expert advice. Treat the content accordingly.
Let’s dive in
We hash plaintext passwords so that we as developers don’t have access to the actual password. When the user table gets compromised the hacker does not get direct access to the user’s password since the hash is a one-way representation of the input. Like most things with security, we create separation and layers between resources so that when a hacker gets access, we limit the impact of the breach. Since users often reuse passwords, a leaked password could lead to other accounts being compromised.
So, what are some considerations we should do to handle passwords securely? Firstly, our choice of hashing algorithm matters. Secondly, we can utilize techniques like salting and peppering to make the hash more unique and in turn it is harder for the hacker to gain the users’ original password.
Hashing passwords: Why not bcrypt?
Bcrypt is one of the most widely used hashing algorithms for passwords. According to Owasp your first choice should be Argon2, followed by scrypt and lastly bcrypt if the other can’t fit your use case. Even though bcrypt is safe given a high enough work factor (Owasp suggests above 10), there are two caveats with bcrypt you should be aware of.
It has a limitation of a maximum of 72 byte input. Even though this should be enough for a strong password, the limitation does not sit well with me. If the user wants a ridiculously long generated password, they should be able to. The second aspect of bcrypt is combining it with other methods such as pre-hashing, salting and peppering. You’ll find examples of the specific limitations in the Owasp cheat sheet. Let’s use Argon2 instead.
Argon2 was the winner of Password Hashing Competition which ran from 2013-2015 and is widely considered a best-in-class key derivation function (KDF) for hashing passwords securely. It comes in three variants Argon2d, Argon2i and Argon2id. Argon2id combines features from the other two and offers a balanced profile which is considered the best for password hashing.
When implementing Argon2, several parameters can be tuned:
- Memory: The amount of memory used in kibibytes.
- Iterations: The number of times the algorithm runs.
- Parallelism: Number of threads used.
- Salt Length: Random salt length.
- Key Length: Length of the derived key.
Owasp recommends with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism when working with argon2id. An important note is that changing the parallelism parameter changes the derived output. You must use the same parallelism config when comparing credentials from registration and login.
Note on salting
Argon2id takes a “salt” as one of its arguments. A salt is a distinctive, randomly generated string incorporated into each password during the hashing process. Since each salt is unique to the individual user, an attacker is forced to crack hashes individually, using the respective salt, rather than computing a hash once and applying it against all stored hashes. This significantly increases the difficulty of cracking numerous hashes, as the time needed expands proportionally with the number of hashes.
Using salting also defends against attackers who rely on precomputed hashes, like those found in rainbow tables or database lookups. Additionally, salting makes it impossible to discern if two users share the same password without cracking the hashes, because each salt leads to a different hash even if the passwords are identical.
You will find many articles and tutorials which use a single static salt across all users. Don’t do this.
Since the salt is randomly generated per user you need to store the salt in your database. I will show how to do this below.
Go implementation
Finally, let’s get into the weeds of implementing hashing and verification of passwords in Go. There is a great post from Alex Edwards on how to do so.
Hashing passwords
You need to get the module from golang.org/x/crypto/argon2 to get started.
$ go get golang.org/x/crypto/argon2
I’m creating an auth module under internal. We start by creating a struct to hold the Argon2 configurations and a DefaultParams variable to hold our desired values. These can of course be passed in as flags from main instead.
package auth
type Params struct {
Memory uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
var DefaultParams = &Params{
Memory: 64 * 1024,
Iterations: 2,
Parallelism: 1,
SaltLength: 16,
KeyLength: 32,
}
The GenerateFromPassword function takes a plaintext password and a pointer to a Params struct and returns the hashed password. We use the argon2.IDkey method for generating the hash.
func GenerateFromPassword(password string, p *Params) (hash string, err error) {
salt, err := generateRandomBytes(p.SaltLength)
if err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), []byte(salt), p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
return hash, nil
}
To generate a unique randomly generated salt we create the generateRandomBytes function. We use the crypto module with rand.read() method to fill the byte slice with values. In DefaultParams we suggest 16 bytes which is a 128-bit entropy value, which can be considered “random enough” for this use case.
func generateRandomBytes(n uint32) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}
We can test the auth module from our main function.
package main
import (
"fmt"
"log"
auth "github.com/Torkel-Aannestad/Argon2-Go-Guide/internal"
)
func main() {
plaintextPassword := "SecretPassword"
hash, err := auth.GenerateFromPassword(plaintextPassword, auth.DefaultParams)
if err != nil {
log.Fatal(err)
}
fmt.Println(hash)
}
$ go run main.go
[252 47 43 240 177 228 57 181 167 181 51 16 187 109 229 224 31 29 242 201 101 18 99 242 141 85 226 69 169 203 207 13]
How do we save it in out database?
How to store the hash and the salt? We want to store the hash, the salt and the configuration arguments passed to the Argon2id algorithm. Over time we may want to change the input parameters to use more memory or more iterations, this way we can reproduce the output and are able to migrate to new config.
To store this information we create an encoded string and store it in a single field in our database. This way it’s easy to work with in application code. In the official argon2 command line utility we find the following output:
$ echo -n "password" | ./argon2 somesalt -t 2 -m 16 -p 4 -l 24
Type: Argon2i
Iterations: 2
Memory: 65536 KiB
Parallelism: 4
Hash: 45d7ac72e76f242b20b77b9bf9bf9d5915894e669a24e6c6
# encoded output with configurations used
Encoded: $argon2i$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
0.188 seconds
Verification ok
The encoded string
$argon2i$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
are separated by “$” and provide the following variables:
- argon2i is algorithm used
- v is algorithm version
- m=65536,t=2,p=4 is memory, iterations/time and parallelism
- base64 encoded salt
- base64 encoded hash
You may find it strange that we are storing salt and hash together, but this is the suggested way of doing it. Firstly, the salt is not more secret than the hash. They are equal parts that won’t work if the other part is not present. By storing them separately you increase the risk of human mistakes, such as giving all users the same salt. Secondly, the role of salt is not to be secret, but to create better separation between different users’ passwords. We can use a technique called “peppering” to encrypt the encoded string with a secret. We discuss this later in the article.
Let’s update our GenerateFromPassword to return a encoded string instead of a byte slice hash.
func GenerateFromPassword(password string, p *Params) (encodedHash string, err error) {
salt, err := generateRandomBytes(p.SaltLength)
if err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), []byte(salt), p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
//we base64 encoded both the salt and the hash.
base64Salt := base64.RawStdEncoding.EncodeToString(salt)
base64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.Memory, p.Iterations, p.Parallelism, base64Salt, base64Hash)
return encodedHash, nil
}
By running the code now we get the following output:
$ go run main.go
$argon2id$v=19$m=65536,t=2,p=1$7i1Sb9+qHrxCCsmL4W5I8g$9+0ivPwf6zML+5zYjJlJy7alSUubtPuBglEFzF/Z6NA
Verifying passwords
When we are verifying the user’s password we hash the password using the same configurations we have stored for the user. We then compare the two hashes and if they match we grant the user access.
Since we retrieve the encoded string from the database we need to decode it into separate pieces. To do so we create a “decodeHash” function.
func decodeHash(encodedHash string) (p *Params, salt, hash []byte, err error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, ErrInvalidHash
}
var version int
_, err = fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil,
}
p = &Params{}
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism)
if err != nil {
return nil, nil, nil, err
}
salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil {
return nil, nil, nil, err
}
p.SaltLength = uint32(len(salt))
hash, err = base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil {
return nil, nil, nil, err
}
p.KeyLength = uint32(len(hash))
return p, salt, hash, nil
}
func ComparePasswordAndHash(password, encodedHash string) (match bool, err error) {
p, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
//reproduced from the password from login form
inputHash := argon2.IDKey([]byte(password), []byte(salt), p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
//we use ConstantTimeCompare to help mitigate timing attacks.
if subtle.ConstantTimeCompare(hash, inputHash) == 1 {
return true, nil
} else {
return false, nil
}
}
//error messages
var (
ErrInvalidHash = errors.New("the encoded hash is not in the correct format")
ErrIncompatibleVersion = errors.New("incompatible version of argon2")
)
Let’s update main to test the ComparePasswordAndHash function
func main() {
plaintextPassword := "SecretPassword"
encodedHash, err := auth.GenerateFromPassword(plaintextPassword, auth.DefaultParams)
if err != nil {
log.Fatal(err)
}
passwordUsedInLoginForm := "Differentpassword"
match, err := auth.ComparePasswordAndHash(passwordUsedInLoginForm, encodedHash)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Match: %v\n", match)
}
$ go run main.go
Match: false
We update main to use the same password for both
func main() {
plaintextPassword := "SecretPassword"
encodedHash, err := auth.GenerateFromPassword(plaintextPassword, auth.DefaultParams)
if err != nil {
log.Fatal(err)
}
//Updated
passwordUsedInLoginForm := "SecretPassword"
match, err := auth.ComparePasswordAndHash(passwordUsedInLoginForm, encodedHash)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Match: %v\n", match)
}
We get…
$ go run main.go
Match: true
Peppering and data encryption
To increase security depth a technique called peppering can be used. This is where you encrypt the Argon2 output with a secret key before storing it in the database. If an adversary gets access to the database or have managed to do a SQL injection attack the hashes will be worthless without the secret key. This could add an additional layer of security, however, if the attacker have access to the server where the application and database are running they likely have access to the secret key as well. For large teams peppering can add a extra barrier against internal threats such as rogue employee stealing data, given that the production secret are handled securely.
Let’s take things a step further and encrypt the encoded hashes.
In our auth module we create an encrypt function which takes the encoded argon2 output and a secret key as input. We want to utilize a symmetric encryption algorithm in this operation. We have several options with pros and cons for different scenarios. A common choice is to use GCM (Galois/Counter Mode) with AES (Advance Encryption Standard) as it’s cipher block. The GCM algorithm is considered high performance and provides both data authenticity (integrity) and confidentiality. One important thing to note is that the size of the secret key matters. AES-128 or AES-256 are selected based on the key argument, so your key should be 16 or 32 bytes.
func encrypt(encodedHash string, secretKey []byte) (encryptedHash string, err error) {
//setting up a cipher block from the aes.NewCipher method. We use a 32 bytes key here.
cipherBlock, err := aes.NewCipher(secretKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(cipherBlock)
if err != nil {
return "", err
}
//Create a random nonce with the appropriate size.
nonce, err := generateRandomBytes(uint32(gcm.NonceSize()))
if err != nil {
return "", err
}
//Use the Seal method with the nonce to encrypt our data.
cipherText := gcm.Seal(nonce, nonce, []byte(encodedHash), nil)
//The Seal method returns a byte slice, hence we encode it with base64 (or hex) to store in database.
encryptedHash = base64.RawStdEncoding.EncodeToString(cipherText)
return encryptedHash, nil
}
In main we add a secret key and we pass it to GenerateFromPassword. In a production scenario manage your secret securely (see Owasp's recommendations for handling secrets).
func main() {
plaintextPassword := "SecretPassword"
secretKey := []byte("passphrasewhichneedstobe32bytes!")
encryptedHash, err := auth.GenerateFromPassword(plaintextPassword, secretKey, auth.DefaultParams)
if err != nil {
log.Fatal(err)
}
...
}
In GenerateFromPassword add the encrypt function
func GenerateFromPassword(password string, secretKey []byte, p *Params) (encryptedHash string, err error) {
salt, err := generateRandomBytes(p.SaltLength)
if err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), []byte(salt), p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
base64Salt := base64.RawStdEncoding.EncodeToString(salt)
base64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.Memory, p.Iterations, p.Parallelism, base64Salt, base64Hash)
//use the encrypt function.
encryptedHash, err = encrypt(encodedHash, secretKey)
if err != nil {
return "", err
}
return encryptedHash, nil
}
The decrypt function is very similar. We set up cipherBlock and GCM. Then we decode the base64 string into a byte slice. Followed by slitting the cipher text and using it’s parts with the gcm.Open() method.
func decrypt(encryptedData string, secretKey []byte) (string, error) {
cipherBlock, err := aes.NewCipher(secretKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(cipherBlock)
if err != nil {
return "", err
}
//Decode the string to byte slice
ciphertext, err := base64.RawStdEncoding.DecodeString(encryptedData)
if err != nil {
return "", err
}
if len(ciphertext) < gcm.NonceSize() {
return "", err
}
//split the cipherText into two based on the nonce size.
nonce, cipherData := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
//Use the Open method with the two parts and we get the encodedHash value.
encodedHash, err := gcm.Open(nil, nonce, cipherData, nil)
if err != nil {
return "", err
}
return string(encodedHash), nil
}
Add decrypt function to ComparePasswordAndHash.
func ComparePasswordAndHash(password, encryptedHash string, secretKey []byte) (match bool, err error) {
encodedHash, err := decrypt(encryptedHash, secretKey)
if err != nil {
return false, err
}
p, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
inputHash := argon2.IDKey([]byte(password), []byte(salt), p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
if subtle.ConstantTimeCompare(hash, inputHash) == 1 {
return true, nil
} else {
return false, nil
}
}
And finally we can test it with main.
func main() {
plaintextPassword := "SecretPassword"
secretKey := []byte("passphrasewhichneedstobe32bytes!")
encryptedHash, err := auth.GenerateFromPassword(plaintextPassword, secretKey, auth.DefaultParams)
if err != nil {
log.Fatal(err)
}
passwordUsedInLoginForm := "Differentpassword"
match, err := auth.ComparePasswordAndHash(passwordUsedInLoginForm, encryptedHash, secretKey)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Match: %v\n", match)
}
You can find the code used in this article in this Github repo.
Thanks for reading! ❤️