TechEarl

How to Store an Argon2 Password Hash in MySQL

Store an Argon2id password hash in MySQL or MariaDB the right way: VARCHAR(255), never a fixed-width column. The encoded format, why its length varies, computing it in PHP / Python / Node, OWASP parameters, and a worked users schema.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
How to store an Argon2id password hash in MySQL: use VARCHAR(255) for the variable-length encoded string, never a fixed CHAR(60), with the hash computed in the application via PHP password_hash, Python argon2-cffi, or the Node argon2 package.

An Argon2id hash is a self-describing string like $argon2id$v=19$m=65536,t=4,p=1$<salt>$<hash>. It carries the algorithm, version, parameters, salt, and digest inline, and its length varies with the parameters you pick. MySQL has no Argon2 function, so you compute the hash in your application and store the finished string. The column to put it in is VARCHAR(255) with an ASCII charset. Below is the format breakdown, why a fixed-width column is the wrong call, how to hash and verify in PHP / Python / Node, the parameters worth setting, and a worked users schema.

Short answer: store the application-computed Argon2id encoded hash in a VARCHAR(255) column. Never a fixed CHAR(60) or a tight VARCHAR(96). The encoded string's length changes with the salt length, hash length, and the m/t/p parameters, so any fixed width either truncates a longer hash silently or pins you to one parameter set. VARCHAR(255) future-proofs the column against a parameter bump or an algorithm change with no schema migration. This is the opposite trade-off from storing a SHA-256 hash in MySQL, where the digest is a fixed size and a BINARY(N) column is the right answer; a password hash is variable-length on purpose.

What an Argon2id hash looks like

Argon2 won the Password Hashing Competition in July 2015. It has three variants: Argon2d (data-dependent, fastest, side-channel-exposed), Argon2i (data-independent, side-channel-resistant), and Argon2id, a hybrid that runs the first half of the first pass in Argon2i mode and the rest in Argon2d mode. Argon2id is OWASP's first-choice algorithm for password storage because it resists both side-channel attacks and GPU cracking, so it is the variant you store.

A real Argon2id hash from PHP's password_hash() looks like this:

text
$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG

Each $-delimited segment carries a piece of the recipe:

SegmentExampleMeaning
Algorithmargon2idThe variant. Also argon2i or argon2d.
Versionv=19Argon2 version 0x13 (1.3), the current encoding.
Parametersm=65536,t=4,p=1Memory in KiB, time (passes), parallelism.
Saltc29tZXNhbHR2YWx1ZQBase64, no padding.
HashRdescudvJCsgt3ub+b+dWRWJTmaaJObGThe derived key, Base64, no padding.

Because the salt, the digest, and the parameters all live inside the string, the verifier needs nothing but the stored value and the candidate password to check a login. There is no separate salt column, no separate parameter column. The hash is the whole record.

The column: why it must be VARCHAR(255)

The PHP default Argon2id hash lands at around 95 to 100 characters. That number is not a constant. The encoded length moves with:

  • Salt length. The default is 16 bytes, which Base64-encodes to 22 characters. A longer salt encodes longer.
  • Hash (digest) length. The default output is 32 bytes, 43 Base64 characters. Raise it and the string grows.
  • The m, t, p parameters. m=65536 is five characters; m=1048576 is seven. The parameter substring itself changes width.

So you cannot pin the column to the length you measured today. This is exactly where people get burned: bcrypt produces a fixed 60-character string, so CHAR(60) works for bcrypt and a lot of tutorials cargo-cult that width onto Argon2. It does not fit. A VARCHAR(96) chosen to match one observed Argon2id hash silently truncates the moment you raise the memory cost or move to a longer digest, and a truncated hash never verifies, so every affected user is locked out.

HashEncoded lengthRight column
bcryptFixed 60 charsCHAR(60) works (but VARCHAR(255) is still fine)
Argon2id~95-100 chars, variableVARCHAR(255)
scrypt (PHC string)VariableVARCHAR(255)

VARCHAR(255) is the standard, boring, correct answer. It is variable-length so you pay only for the bytes each hash actually uses (the encoded value plus a 1-byte length prefix), it comfortably holds any sane Argon2 parameter set, and it survives a future migration to a different algorithm without a schema change. Use an ASCII charset, since the encoded value is pure ASCII:

sql
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL

The binary collation keeps comparisons case-sensitive and byte-exact, which is what you want, though you should never be comparing the column with = against a password anyway (see the verify step below). Storing the value in latin1 or ascii instead of utf8mb4 also avoids reserving four bytes per character for a string that is never multibyte. For broad portability VARCHAR(255) is the value I reach for; some teams use VARCHAR(97) or VARCHAR(128) to signal intent, but the headroom of 255 costs nothing on a variable-length column and removes a whole class of future surprises. The same reasoning, algorithm-agnostic, is laid out in why a password column should be VARCHAR(255).

Computing and verifying in your app

The hash is computed in your application, never in MySQL. There is no ARGON2() SQL function in MySQL or MariaDB. The database stores whatever string the app hands it. Here are the three common stacks.

PHP (7.3+ for Argon2id) uses password_hash() and password_verify():

php
<?php
// hash on signup / password change
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // KiB (64 MiB)
    'time_cost'   => 4,      // passes
    'threads'     => 1,      // parallelism
]);
// store $hash in the VARCHAR(255) column

// verify on login
if (password_verify($candidate, $hash)) {
    // authenticated
}

PHP added PASSWORD_ARGON2I in 7.2 (November 2017) and PASSWORD_ARGON2ID in 7.3 (December 2018), which is why this article is dated 2019: you could not store an Argon2id hash from stock PHP before then. If you omit the options array, PHP uses PASSWORD_ARGON2_DEFAULT_MEMORY_COST, PASSWORD_ARGON2_DEFAULT_TIME_COST, and PASSWORD_ARGON2_DEFAULT_THREADS, which in current PHP are 65536, 4, and 1 respectively, the same values written out explicitly above.

Python uses the argon2-cffi package (PasswordHasher defaults to Argon2id):

python
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(memory_cost=65536, time_cost=4, parallelism=1)

# hash
encoded = ph.hash(password)   # store this string

# verify
try:
    ph.verify(encoded, candidate)
except VerifyMismatchError:
    pass  # wrong password

Node.js uses the argon2 package, which defaults to Argon2id:

javascript
const argon2 = require("argon2");

// hash
const encoded = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,
  timeCost: 4,
  parallelism: 1,
});

// verify
const ok = await argon2.verify(encoded, candidate);

In all three the verify call re-reads the parameters and salt out of the stored string, recomputes, and compares in constant time. You hand it the column value and the candidate password and nothing else.

Choosing parameters

The three knobs are memory (m), time/iterations (t), and parallelism (p). Argon2's headline advantage over bcrypt is that it is memory-hard: the memory cost forces an attacker to commit real RAM per guess, which is what blunts the GPU and ASIC farms that make fast hashes cheap to crack. Turn memory down to nothing and you throw that away.

The OWASP Password Storage Cheat Sheet gives a set of equivalent minimums for Argon2id, any one of which is acceptable:

  • m=47104 (46 MiB), t=1, p=1
  • m=19456 (19 MiB), t=2, p=1
  • m=12288 (12 MiB), t=3, p=1
  • m=9216 (9 MiB), t=4, p=1
  • m=7168 (7 MiB), t=5, p=1

These are floors, not targets. PHP's current defaults (64 MiB, 4 passes, 1 thread) sit comfortably above the minimums. The practical method is to raise m and t until a single hash takes roughly 0.5 to 1 second on your production hardware, then back off if that hurts login throughput under load.

Because the parameters are baked into the stored string, you can raise them over time without a flag day. On a successful login, check whether the stored hash used weaker parameters than your current policy and, if so, rehash the just-verified plaintext and update the row:

php
<?php
if (password_verify($candidate, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID, $options)) {
        $newHash = password_hash($candidate, PASSWORD_ARGON2ID, $options);
        // UPDATE users SET password_hash = ? WHERE id = ?
    }
}

password_needs_rehash() reads the parameters out of the encoded string and compares them to your target. This is the other reason the column has to be VARCHAR(255): a stronger future parameter set produces a longer string, and you need the room to store it. (The Python and Node libraries expose the same idea via PasswordHasher.check_needs_rehash() and argon2.needsRehash().)

The same mechanism migrates a legacy algorithm in place. If a table already holds bcrypt hashes (the $2y$ prefix) and you want to move to Argon2id, you do not need a flag-day rehash of every row, which is impossible anyway since you do not hold the plaintexts. On each successful login you verify against whatever scheme produced the stored hash (password_verify() detects bcrypt and Argon2id from the prefix transparently), then rehash the just-verified plaintext under Argon2id and update the row. The table holds a mix of $2y$ and $argon2id$ strings during the transition and converges as users log in. A single VARCHAR(255) column holds both formats without change, which is the practical payoff of sizing for the algorithm-agnostic case rather than to one hash's measured width.

A worked schema

Put it together. The hash is application-computed and dropped into a VARCHAR(255) column; the lookup key is the email (or username), and the password is verified in code, never in SQL.

sql
CREATE TABLE users (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  email         VARCHAR(320) NOT NULL,
  password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
  created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_email (email)
) ENGINE=InnoDB;

Signup inserts the encoded hash your application produced:

sql
INSERT INTO users (email, password_hash)
VALUES (?, ?);   -- second param = password_hash($pw, PASSWORD_ARGON2ID, ...)

Login is a two-step dance: fetch the row by the indexed email, then verify in the application. You never write WHERE password_hash = ...; you cannot, because a fresh hash of the same password produces a different salt and therefore a different string every time.

sql
SELECT id, password_hash FROM users WHERE email = ?;
-- then in app code: password_verify($candidate, $row['password_hash'])

There is no index on password_hash and there should not be. It is never a search key; it is fetched by primary or unique key and handed to the verifier. The UNIQUE KEY on email is the access path.

Everything above is identical on MariaDB. There is no native Argon2 function there either, so the column is the same VARCHAR(255) storing an app-computed string, and the PHP / Python / Node code does not change. For the general rules on sizing variable-length string columns, see MySQL data types and sizes. The bcrypt equivalent (a fixed 60-character CHAR(60)) is covered separately in storing a bcrypt hash in MySQL, and the broader question of which column type to use for credentials is covered in what column type a password should be.

A closing reminder on what not to store: a bare fast hash. Putting SHA2(password, 256) or, worse, MD5(password) in a column is the mistake Argon2 exists to fix, because a fast hash lets an attacker brute-force a stolen table at billions of guesses per second. Argon2id's deliberate memory and time cost is the whole point.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsMySQLArgon2Argon2idPassword StorageSchema DesignVARCHARHashingSecurity

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store a bcrypt Password Hash in PostgreSQL

A bcrypt hash is a fixed 60-character string. In PostgreSQL the right column is text (varchar(255) is equivalent). Why text over char(60), app-side hashing, the pgcrypto crypt() option, and a worked users schema.

How to Store a Phone Number in MySQL

Store a phone number in MySQL as a string, never an integer. Normalize to E.164 and use VARCHAR(16), index it for lookups, and keep the raw input in a second column. Worked schema for MySQL and MariaDB.