Skip to content

Argon2 Hash & Verify — C# source

Hash passwords with Argon2id — the winner of the Password Hashing Competition. Configure memory, iterations, and parallelism. WASM-powered, client-side.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Argon2 — Argon2id password hashing (PHC parse/format logic in pure C#;
// the KDF itself via the reference argon2(1) CLI).
// C# 12 / .NET 8 — ported from src/lib/argon2.ts (the canonical TypeScript
// implementation). Display source for CosmoDev's polyglot pages.
//
// The TS build drives the reference C library compiled to WASM
// (argon2-browser). .NET has no Argon2 in System.Security.Cryptography or
// anywhere else in the BCL, so this port drives the same reference C library
// through its CLI: `argon2` (https://github.com/P-H-C/phc-winner-argon2)
// must be on PATH for Hash / Verify. The PHC parser, parameter validator and
// Base64 codec are pure C# and dependency-free.
//
// PHC string format (what `Encoded` holds - the string you store in a DB):
//   $argon2id$v=19$m=65536,t=3,p=1$<b64 salt>$<b64 digest>
// Salt and digest are unpadded standard Base64.
//
// The CLI takes its salt as one argv value, and argv travels through the
// platform's native encoding - raw binary salt bytes >= 0x80 do not arrive
// byte-for-byte. Random salts are therefore drawn as 128 bits rendered in 32
// lowercase hex chars (always ASCII-safe), and Hash reads the effective salt
// and digest straight back out of the PHC string the CLI prints: the returned
// Hash / Salt / Encoded always describe the salt that was actually used.

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.RegularExpressions;

/// <summary>Optional Argon2 hashing parameters (defaults follow ARGON2_DEFAULTS).</summary>
public sealed class Argon2Options
{
    /// <summary>Memory cost in KiB (default 65536 = 64 MiB). Must be &gt;= 1024.</summary>
    public int? Memory { get; init; }
    /// <summary>Time cost - passes over memory (default 3). Must be &gt;= 1.</summary>
    public int? Iterations { get; init; }
    /// <summary>Parallelism - lanes (default 1). Must be &gt;= 1.</summary>
    public int? Parallelism { get; init; }
    /// <summary>Digest length in bytes (default 32). Must be 16..64.</summary>
    public int? HashLength { get; init; }
}

/// <summary>Parameters extracted from a PHC string (Parse's return type).</summary>
public sealed record Argon2Params(
    /// <summary>"argon2d" | "argon2i" | "argon2id".</summary>
    string Type,
    int Version,
    int Memory,
    int Iterations,
    int Parallelism,
    /// <summary>Salt, decoded from the embedded Base64 into lowercase hex.</summary>
    string Salt,
    /// <summary>Digest, decoded from the embedded Base64 into lowercase hex ("" if absent).</summary>
    string Hash);

/// <summary>The outcome of a Hash call.</summary>
public sealed record Argon2Result(string Hash, string Encoded, string Salt);

public static class Argon2
{
    /// <summary>Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes).</summary>
    public const int DefaultMemory = 65_536;
    public const int DefaultIterations = 3;
    public const int DefaultParallelism = 1;
    public const int DefaultHashLength = 32;

    /// <summary>Random salt size, in hex characters (128 bits, drawn as ASCII-safe hex).</summary>
    public const int SaltChars = 32;

    private static readonly Regex PhcRegex = new(
        @"^\$(argon2(?:d|i|id))\$v=(\d+)\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)(?:\$([A-Za-z0-9+/]+))?$",
        RegexOptions.Compiled);

    private static string BytesToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();

    /// <summary>char -> 6-bit value for the standard Base64 alphabet.</summary>
    private static int B64Value(char c) => c switch
    {
        >= 'A' and <= 'Z' => c - 'A',
        >= 'a' and <= 'z' => c - 'a' + 26,
        >= '0' and <= '9' => c - '0' + 52,
        '+' => 62,
        '/' => 63,
        _ => -1,
    };

    /// <summary>
    /// Unpadded standard Base64 (the PHC encoding) -> bytes. Throws on any
    /// non-alphabet character or an impossible length (1 mod 4).
    /// </summary>
    private static byte[] PhcBase64ToBytes(string b64)
    {
        if (b64.Length == 0) throw new FormatException("Invalid Argon2 string: empty Base64 field");
        foreach (var c in b64)
        {
            if (B64Value(c) < 0) throw new FormatException("Invalid Argon2 string: non-Base64 characters");
        }
        if (b64.Length % 4 == 1) throw new FormatException("Invalid Argon2 string: impossible Base64 length");
        var outputLength = b64.Length / 4 * 3;
        if (b64.Length % 4 == 2) outputLength = (b64.Length - 2) / 4 * 3 + 1;
        if (b64.Length % 4 == 3) outputLength = (b64.Length - 3) / 4 * 3 + 2;
        var bytes = new byte[outputLength];
        var p = 0;
        for (var i = 0; i < b64.Length; i += 4)
        {
            // Length was validated above (not 1 mod 4), so every group has >= 2 chars.
            var d0 = B64Value(b64[i]);
            var d1 = B64Value(b64[i + 1]);
            if (p < outputLength) bytes[p++] = (byte)((d0 << 2) | (d1 >> 4));
            if (i + 2 < b64.Length)
            {
                var d2 = B64Value(b64[i + 2]);
                if (p < outputLength) bytes[p++] = (byte)(((d1 & 0x0f) << 4) | (d2 >> 2));
                if (i + 3 < b64.Length)
                {
                    var d3 = B64Value(b64[i + 3]);
                    if (p < outputLength) bytes[p++] = (byte)(((d2 & 0x03) << 6) | d3);
                }
            }
        }
        return bytes;
    }

    /// <summary>
    /// Parse a PHC-format Argon2 string ($argon2id$v=19$m=65536,t=3,p=1$salt$hash)
    /// into its typed parameters. Accepts argon2d / argon2i / argon2id. The
    /// digest segment is optional (some encoders omit it); salt and hash are
    /// returned as lowercase hex. Throws on any malformed input.
    /// </summary>
    public static Argon2Params Parse(string encoded)
    {
        var m = PhcRegex.Match(encoded.Trim());
        if (!m.Success)
        {
            throw new FormatException(
                "Invalid Argon2 string: expected $argon2id$v=19$m=…,t=…,p=…$salt$hash");
        }
        return new Argon2Params(
            m.Groups[1].Value,
            int.Parse(m.Groups[2].Value),
            int.Parse(m.Groups[3].Value),
            int.Parse(m.Groups[4].Value),
            int.Parse(m.Groups[5].Value),
            BytesToHex(PhcBase64ToBytes(m.Groups[6].Value)),
            m.Groups[7].Success ? BytesToHex(PhcBase64ToBytes(m.Groups[7].Value)) : "");
    }

    /// <summary>Validate + normalise hashing parameters, throwing with a clear message.</summary>
    private static (int Memory, int Iterations, int Parallelism, int HashLength) Normalize(Argon2Options? options)
    {
        var memory = options?.Memory ?? DefaultMemory;
        var iterations = options?.Iterations ?? DefaultIterations;
        var parallelism = options?.Parallelism ?? DefaultParallelism;
        var hashLength = options?.HashLength ?? DefaultHashLength;
        if (memory < 1024) throw new ArgumentOutOfRangeException(nameof(options), "Memory must be at least 1024 KiB");
        if (iterations < 1) throw new ArgumentOutOfRangeException(nameof(options), "Iterations must be at least 1");
        if (parallelism < 1) throw new ArgumentOutOfRangeException(nameof(options), "Parallelism must be at least 1");
        if (hashLength < 16 || hashLength > 64)
        {
            throw new ArgumentOutOfRangeException(nameof(options), "Hash length must be between 16 and 64 bytes");
        }
        return (memory, iterations, parallelism, hashLength);
    }

    /// <summary>The CLI's type flag for an Argon2 variant name.</summary>
    private static string TypeFlag(string type) => type switch
    {
        "argon2d" => "-d",
        "argon2i" => "-i",
        "argon2id" => "-id",
        _ => throw new FormatException($"Unsupported Argon2 type: {type}"),
    };

    /// <summary>
    /// Run the reference argon2(1) CLI. The password arrives via stdin, the
    /// salt and parameters as an argument ARRAY (never a command string), so
    /// no shell ever parses either. Returns the CLI's stdout.
    /// </summary>
    private static string RunArgon2(IReadOnlyList<string> arguments, string password)
    {
        var start = new ProcessStartInfo("argon2")
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            RedirectStandardInput = true,
            UseShellExecute = false,
        };
        foreach (var argument in arguments)
        {
            start.ArgumentList.Add(argument);
        }
        using var process = Process.Start(start)
            ?? throw new InvalidOperationException(
                "argon2 CLI not found on PATH (https://github.com/P-H-C/phc-winner-argon2).");
        process.StandardInput.Write(password);
        process.StandardInput.Close();
        var stdout = process.StandardOutput.ReadToEnd();
        var stderr = process.StandardError.ReadToEnd();
        process.WaitForExit();
        if (process.ExitCode != 0)
        {
            throw new InvalidOperationException(
                string.IsNullOrWhiteSpace(stderr) ? $"argon2 exited with {process.ExitCode}." : stderr.Trim());
        }
        return stdout;
    }

    private static string StdoutField(string stdout, string label)
    {
        var line = stdout.Split('\n').FirstOrDefault(l => l.TrimStart().StartsWith(label, StringComparison.Ordinal));
        if (line == null) throw new InvalidOperationException($"argon2 output carried no {label} line.");
        return line.Split(':', 2)[1].Trim();
    }

    /// <summary>
    /// Hash a password with Argon2id (hybrid of Argon2i's side-channel
    /// resistance and Argon2d's GPU resistance - the Password Hashing
    /// Competition winner and the recommended mode for password storage).
    /// Returns the digest (hex), the salt used (hex), and the self-contained
    /// PHC string. A fresh random salt is generated per call.
    /// </summary>
    public static Argon2Result Hash(string password, Argon2Options? options = null)
    {
        var (memory, iterations, parallelism, hashLength) = Normalize(options);
        // 128 bits drawn as 32 lowercase hex chars: every encoding passes the
        // salt through unchanged, and the CLI reports the effective value back.
        var saltArgument = BytesToHex(RandomNumberGenerator.GetBytes(16));

        var stdout = RunArgon2(
        [
            saltArgument, "-id",
            "-t", iterations.ToString(),
            "-m", memory.ToString(),
            "-p", parallelism.ToString(),
            "-l", hashLength.ToString(),
            "-v", "13", // Argon2 version 1.3 (v=19)
        ], password);

        var hash = StdoutField(stdout, "Hash");
        var encoded = StdoutField(stdout, "Encoded");
        // The effective salt, read back from the PHC string the CLI printed.
        var salt = Parse(encoded).Salt;
        return new Argon2Result(hash.ToLowerInvariant(), encoded, salt);
    }

    /// <summary>
    /// Verify a password against a PHC-format encoded hash (as produced by
    /// Hash). Returns true on match, false on mismatch; throws only on a
    /// malformed encoded string or a runtime error. Any Argon2 type (d/i/id)
    /// is accepted - the type is read from the string itself.
    /// </summary>
    public static bool Verify(string encoded, string password)
    {
        var parameters = Parse(encoded); // validate format up front
        if (parameters.Hash.Length == 0)
        {
            throw new FormatException("Invalid Argon2 string: no digest to verify against.");
        }
        var expected = Convert.FromHexString(parameters.Hash);
        // The stored salt decodes to the exact ASCII bytes that seeded the CLI
        // run that produced this hash - pass them back through unchanged.
        var saltBytes = PhcBase64ToBytes(encoded.Split('$', StringSplitOptions.RemoveEmptyEntries)[3]);
        var saltArgument = System.Text.Encoding.ASCII.GetString(saltBytes);

        var stdout = RunArgon2(
        [
            saltArgument, TypeFlag(parameters.Type),
            "-t", parameters.Iterations.ToString(),
            "-m", parameters.Memory.ToString(),
            "-p", parameters.Parallelism.ToString(),
            "-l", expected.Length.ToString(),
            "-v", parameters.Version == 16 ? "10" : "13",
        ], password);

        var recomputed = StdoutField(stdout, "Hash");
        return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(recomputed), expected);
    }
}

Also available in 9 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →