fady

PHC string format

6 min read

When hashing a password, you provide the plain text password as input and, depending on the hashing algorithm, you would specify a few parameters such as the salt, memory cost, time cost, and the degree of parallelism.

Apart from the salt, these parameters differ by hashing algorithm and can be used to control how expensive the password hash is to compute.

For example, argon2id takes a memory cost, a time cost, and a degree of parallelism whereas bcrypt takes a single cost factor.

When a hash is computed, the output can be represented in several different ways. For example, hashing a password with argon2id:

argon2id("my-password", "somesalt", {
  t: 3, // time
  m: 65536, // memory
  p: 4, // parallelism
});

may produce an output that looks like so:

$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$AkTUg5x...

This is a PHC string.

PHC stands for the Password Hashing Competition which was a competition used to select a new algorithm for hashing passwords. It ran from 2013 to 2015 with Argon2 selected as the winner.

If you inspect the resulting output, you’ll be able to tease out the different parameters we used for computing the hash:

  • algorithm: argon2id
  • version: v=19, which is 0x13 in hex, i.e. Argon2 version 1.3
  • memory cost: m=65536 (64 MiB)
  • time cost: t=3
  • parallelism: p=4
  • salt: c29tZXNhbHQ
  • hash: AkTUg5x...

Typically, you would persist the output string in your database of choice when, say, a user signs up to your website with an email and password.

Then, when they visit your site again and have to sign in, they enter their password, you load their stored password hash, parse the parameters from the PHC string, recompute the hash, and compare the derived hash against the stored hash:

const { id, params, salt, hash } = parsePhc(storedHash);

const derivedHash = argon2id(passwordInput, salt, {
  t: params.t,
  m: params.m,
  p: params.p,
});

const isValid = constantTimeEqual(derivedHash, hash);

The key piece to call out here is that you did not need to hard-code the argon2id parameters. Instead, they are stored in a self-describing format which includes all the parameters necessary to verify the hash.

In practice, you’ll rarely parse PHC strings yourself. Password hashing libraries typically accept the stored string as is, and take care of parsing it, recomputing the hash, and comparing the result in constant time.

The format

A typical PHC string looks like so:

$id$v=version$parameters$salt$hash

The $ is used as a delimiter. Using the argon2id hash from earlier as the example:

Component Description Example
id Name of the hashing algorithm argon2id
v=version Algorithm version, in decimal v=19
parameters Comma-separated name=value pairs, in the order defined by the algorithm. m=65536,t=3,p=4
salt The salt, typically base64-encoded without padding. c29tZXNhbHQ
hash The raw hash output, base64-encoded without padding. AkTUg5x...

Each algorithm defines what parameters it requires. When an optional component is left out, its leading $ is dropped too. The full grammar from the spec is:

$<id>[$v=<version>][$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]

Only the id is required, and the hash can only be present if the salt is.

See the PHC strings specification for the complete grammar and details.

Argon2 isn’t the only algorithm with a PHC encoding. scrypt hashes are commonly written as PHC strings too by some libraries:

$scrypt$ln=17,r=8,p=1$c29tZXNhbHQ$dAHIgQOE7ojhb2Bu1eCRwuofOpwZ6NSTPOy7AvnayuY

bcrypt hashes, on the other hand, are not PHC strings, even though they look similar:

$2y$12$uJba/Xe9MO1Y8/OmK.N5COWIYNXQBqCJXU9HWvnmVYSA3yIC6K8AO

The cost parameter (12) isn’t a name=value pair, there’s no $ between the salt and the hash, and both are encoded with a different base64 alphabet.

bcrypt predates the PHC string format and follows an older set of conventions we will see below.

Why are PHC formatted hashes useful?

The main properties that make the PHC string format useful are that it’s well-defined and self-describing. Everything needed to verify a password (the algorithm, its version, its parameters, and the salt) is encoded in the string itself. In practice, this means:

  • You can raise the cost over time. As hardware gets faster, you’ll want to tune the parameters. Existing hashes keep working because each one carries its own parameters. On a successful login, you can check whether the stored parameters are below your current policy and, if so, rehash the password with the updated parameters.
  • You can migrate between algorithms. The id tells you which algorithm produced each hash, so old and new hashes can live side by side in the same column while users are gradually rehashed on login.
  • Hashes are portable. Since the format is well-defined, a hash produced by one library can be verified by another.

A bit of history: the Modular Crypt Format

The precursor to the PHC string format was the Modular Crypt Format (MCF), which emerged somewhat organically to enable systems to support multiple password hashing algorithms at the same time.

In the early days, Unix’s crypt(3) function, used to verify user passwords during login, relied on a DES-based algorithm to generate password hashes. So when a user logged in and their password input was verified against the stored hash in /etc/passwd, crypt() would assume it was produced by the DES-based algorithm.

Over time, improved algorithms came along, such as MD5-crypt. To be able to support multiple hashing algorithms on the same system, crypt() would need a way to determine which of the stored hashes were produced by DES and which ones by MD5-crypt, in a backward compatible way.

This led to the introduction of the $1$ prefix on stored hashes to indicate they were generated by MD5-crypt.

So when a user logged in, crypt() could check the stored hash for the prefix to decide which algorithm to use:

const isValid = crypt(passwordInput, storedHash) === storedHash;

function crypt(password: string, storedHash: string): string {
  // MD5-crypt: `$1$<salt>$<hash>`
  if (storedHash.startsWith("$1$")) {
    const salt = storedHash.split("$")[2];
    return md5Crypt(password, salt);
  }

  // DES: `<salt><hash>`, where the salt is the first 2 characters
  const salt = storedHash.slice(0, 2);
  return desCrypt(password, salt);
}

Gradually, more prefixes were used as new algorithms were added, such as $2a$, $2b$, and $2y$ for bcrypt, and $5$ and $6$ for SHA-256-crypt and SHA-512-crypt.

This became known as the Modular Crypt Format (MCF), which isn’t really a standard but a set of conventions. The issue was that it did not specify how parameters should be encoded in the output, allowing each algorithm to define its own structure based on what it needs.

For example, compare how three MCF algorithms handle their parameters:

Algorithm Format Parameters
MD5-crypt $1$<salt>$<hash> None
bcrypt $2b$<cost>$<salt><hash> A positional cost, with no $ between the salt and hash
SHA-256-crypt $5$rounds=<rounds>$<salt>$<hash> An optional name=value pair

Each one needs its own parser, and the only thing they have in common is the leading $<id>$ (the algorithm name).

PHC strings take this idea and formalize it into a well-defined grammar to simplify interoperability across algorithms and implementations. The spec describes PHC strings as “a more rigorously-defined subset” of MCF.