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:
$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObGEach $-delimited segment carries a piece of the recipe:
| Segment | Example | Meaning |
|---|---|---|
| Algorithm | argon2id | The variant. Also argon2i or argon2d. |
| Version | v=19 | Argon2 version 0x13 (1.3), the current encoding. |
| Parameters | m=65536,t=4,p=1 | Memory in KiB, time (passes), parallelism. |
| Salt | c29tZXNhbHR2YWx1ZQ | Base64, no padding. |
| Hash | RdescudvJCsgt3ub+b+dWRWJTmaaJObG | The 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,pparameters.m=65536is five characters;m=1048576is 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.
| Hash | Encoded length | Right column |
|---|---|---|
| bcrypt | Fixed 60 chars | CHAR(60) works (but VARCHAR(255) is still fine) |
| Argon2id | ~95-100 chars, variable | VARCHAR(255) |
| scrypt (PHC string) | Variable | VARCHAR(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:
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULLThe 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
// 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):
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 passwordNode.js uses the argon2 package, which defaults to Argon2id:
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=1m=19456(19 MiB),t=2,p=1m=12288(12 MiB),t=3,p=1m=9216(9 MiB),t=4,p=1m=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
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.
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:
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.
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
- Storing a bcrypt hash in MySQL covers the fixed-width
CHAR(60)case this article keeps contrasting against. - Picking the right password column type is the algorithm-agnostic version of the sizing argument here.
- Storing a SHA-256 hash in MySQL shows the opposite trade-off: a fixed-size digest that belongs in a
BINARY(N)column. - Hashing passwords with bcrypt in PostgreSQL is the Postgres equivalent if you are on that stack instead.
- The MySQL field types and sizes reference backs up the general rules for sizing variable-length string columns.
- Running MySQL in Docker gets you a throwaway instance to test the schema above.
Sources
Authoritative references this article was fact-checked against.
- PHP Manual: password_hash() (PASSWORD_ARGON2I, PASSWORD_ARGON2ID, memory_cost/time_cost/threads)php.net
- PHP Manual: Password Constants (PASSWORD_ARGON2_DEFAULT_MEMORY_COST/TIME_COST/THREADS)php.net
- OWASP Password Storage Cheat Sheet: Argon2id recommended parameterscheatsheetseries.owasp.org
- MySQL 8.0 Reference Manual: String Data Types (CHAR, VARCHAR)dev.mysql.com





