Skip to content

Argon2 Hash & Verify — Java source

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

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

// Argon2 — Argon2id password hashing (parse/PHC logic in pure Java; the KDF
// itself via the reference argon2(1) CLI).
//
// Language: Java (17+, standard library only)
// Ported from src/lib/argon2.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// The TS build drives the reference C library compiled to WASM
// (argon2-browser). The JDK has no Argon2 in javax.crypto or anywhere else
// in the standard library, 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 argon2Hash / argon2Verify. The PHC parser, validator,
// and Base64 codec are pure Java 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 an argv string, and Java's ProcessBuilder
// re-encodes argv through the platform's native encoding (UTF-8 on most
// modern systems) - so 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, which every encoding passes through unchanged, and
// argon2Hash reads the effective salt and digest straight back out of the
// PHC string the CLI prints: the returned hash / salt / encoded always
// describe one real, self-consistent hash. (Consequence: the PHC salt field
// of a freshly generated hash holds the 32-byte hex form rather than 16 raw
// bytes - a constraint the TS/WASM build does not have.)

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

public final class Argon2 {

    /** Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes). */
    public static final int DEFAULT_MEMORY = 65_536; // KiB
    public static final int DEFAULT_ITERATIONS = 3;
    public static final int DEFAULT_PARALLELISM = 1;
    public static final int DEFAULT_HASH_LENGTH = 32; // bytes

    /** Random salt size in bytes (128 bits - the PHC recommendation). */
    public static final int SALT_BYTES = 16;

    /** Hashing parameters; every field optional (null -> the OWASP default). */
    public static final class Options {
        /** Memory cost in KiB (default 65536 = 64 MiB). Must be >= 1024. */
        public Integer memory;
        /** Time cost - passes over memory (default 3). Must be >= 1. */
        public Integer iterations;
        /** Parallelism - lanes (default 1). Must be >= 1. */
        public Integer parallelism;
        /** Digest length in bytes (default 32). Must be 16..64. */
        public Integer hashLength;
        /** Explicit salt bytes; a random 16-byte salt is generated when null. */
        public byte[] salt;
    }

    public record Argon2Result(
            /** Raw digest, lowercase hex (hashLength bytes). */
            String hash,
            /** Self-contained PHC string - store this, verify against it. */
            String encoded,
            /** Salt used, lowercase hex (as embedded in the PHC string). */
            String salt) {
    }

    /** Parameters extracted from a PHC string (parseArgon2's return type). */
    public record Argon2Params(
            String type, // "argon2d" | "argon2i" | "argon2id"
            long version,
            long memory,
            long iterations,
            long parallelism,
            /** Salt, decoded from the embedded Base64 into lowercase hex. */
            String salt,
            /** Digest, decoded from the embedded Base64 into lowercase hex ("" if absent). */
            String hash) {
    }

    private static final Pattern PHC_RE = Pattern.compile(
            "\\$(argon2(?:d|i|id))\\$v=(\\d+)\\$m=(\\d+),t=(\\d+),p=(\\d+)\\$([A-Za-z0-9+/]+)(?:\\$([A-Za-z0-9+/]+))?");
    private static final Pattern PHC_B64_RE = Pattern.compile("[A-Za-z0-9+/]+");

    private static final SecureRandom RANDOM = new SecureRandom();

    private Argon2() {
    }

    /** Lowercase hex of a byte array. */
    static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(Character.forDigit((b >> 4) & 0xf, 16));
            sb.append(Character.forDigit(b & 0xf, 16));
        }
        return sb.toString();
    }

    private static byte[] hexToBytes(String hex) {
        byte[] out = new byte[hex.length() / 2];
        for (int i = 0; i < out.length; i++) {
            out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
        }
        return out;
    }

    /**
     * Unpadded standard Base64 (the PHC encoding) -> bytes. Throws on any
     * non-alphabet character or an impossible length (1 mod 4).
     */
    static byte[] phcBase64ToBytes(String b64) {
        if (b64.isEmpty()) {
            throw new IllegalArgumentException("Invalid Argon2 string: empty Base64 field");
        }
        if (!PHC_B64_RE.matcher(b64).matches()) {
            throw new IllegalArgumentException("Invalid Argon2 string: non-Base64 characters");
        }
        if (b64.length() % 4 == 1) {
            throw new IllegalArgumentException("Invalid Argon2 string: impossible Base64 length");
        }
        // Re-pad to a multiple of 4 and let java.util.Base64 do the 6-bit
        // arithmetic; the length checks above already mirror the TS byte counting.
        String padded = b64 + "=".repeat((4 - b64.length() % 4) % 4);
        return java.util.Base64.getDecoder().decode(padded);
    }

    /**
     * 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.
     */
    public static Argon2Params parseArgon2(String encoded) {
        var m = PHC_RE.matcher(encoded.trim());
        if (!m.matches()) {
            throw new IllegalArgumentException(
                    "Invalid Argon2 string: expected $argon2id$v=19$m=…,t=…,p=…$salt$hash");
        }
        return new Argon2Params(
                m.group(1),
                Long.parseLong(m.group(2)),
                Long.parseLong(m.group(3)),
                Long.parseLong(m.group(4)),
                Long.parseLong(m.group(5)),
                bytesToHex(phcBase64ToBytes(m.group(6))),
                m.group(7) == null ? "" : bytesToHex(phcBase64ToBytes(m.group(7))));
    }

    /** Validate + normalise hashing parameters, throwing with a clear message. */
    private static record Normalized(int memory, int iterations, int parallelism, int hashLength) {
    }

    private static Normalized normalizeOptions(Options options) {
        int memory = options != null && options.memory != null ? options.memory : DEFAULT_MEMORY;
        int iterations = options != null && options.iterations != null ? options.iterations : DEFAULT_ITERATIONS;
        int parallelism = options != null && options.parallelism != null ? options.parallelism : DEFAULT_PARALLELISM;
        int hashLength = options != null && options.hashLength != null ? options.hashLength : DEFAULT_HASH_LENGTH;
        if (memory < 1024) throw new IllegalArgumentException("Memory must be at least 1024 KiB");
        if (iterations < 1) throw new IllegalArgumentException("Iterations must be at least 1");
        if (parallelism < 1) throw new IllegalArgumentException("Parallelism must be at least 1");
        if (hashLength < 16 || hashLength > 64) {
            throw new IllegalArgumentException("Hash length must be between 16 and 64 bytes");
        }
        return new Normalized(memory, iterations, parallelism, hashLength);
    }

    /** Run a command, feed it stdin, return stdout; throws with stderr on failure. */
    private static String run(List<String> command, String stdin) throws IOException, InterruptedException {
        Process p = new ProcessBuilder(command).start();
        p.getOutputStream().write(stdin.getBytes(StandardCharsets.UTF_8));
        p.getOutputStream().close();
        String stdout = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        String stderr = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
        if (p.waitFor() != 0) {
            throw new IOException("argon2 failed: " + stderr.trim());
        }
        return stdout;
    }

    /**
     * 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, as read back from the PHC string), and the self-contained
     * PHC string. A fresh random 16-byte salt is generated per call unless
     * options.salt is given.
     */
    public static Argon2Result argon2Hash(String password, Options options) throws IOException, InterruptedException {
        Normalized opts = normalizeOptions(options);
        byte[] salt = options != null && options.salt != null ? options.salt : randomSalt();

        // The CLI's -m flag is log2(memory in KiB); memory is always a power of
        // two in practice (the default 65536 = -m 16) and non-powers round to
        // the nearest flag the CLI accepts, like every other CLI driver.
        List<String> command = new ArrayList<>(List.of(
                "argon2", new String(salt, StandardCharsets.ISO_8859_1),
                "-id", "-v", "13", "-e",
                "-m", Integer.toString((int) Math.round(Math.log(opts.memory()) / Math.log(2))),
                "-t", Integer.toString(opts.iterations()),
                "-p", Integer.toString(opts.parallelism()),
                "-l", Integer.toString(opts.hashLength())));
        String encoded = run(command, password).trim();

        // Read the digest and the salt that was actually used back out of the
        // PHC string (see the class comment on argv encoding).
        Argon2Params produced = parseArgon2(encoded);
        return new Argon2Result(produced.hash(), encoded, produced.salt());
    }

    /**
     * A fresh random salt: SALT_BYTES CSPRNG bytes rendered as 32 lowercase hex
     * chars (128 bits of entropy). Hex text is pure ASCII, so it survives the
     * ProcessBuilder argv round-trip byte-for-byte under any native encoding -
     * raw binary salt bytes >= 0x80 would not (see the class comment).
     */
    static byte[] randomSalt() {
        byte[] seed = new byte[SALT_BYTES];
        RANDOM.nextBytes(seed);
        return bytesToHex(seed).getBytes(java.nio.charset.StandardCharsets.US_ASCII);
    }

    /**
     * Verify a password against a PHC-format encoded hash (as produced by
     * argon2Hash). 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.
     *
     * The CLI has no verify mode, so this recomputes the hash with the
     * embedded parameters + salt and compares digests (what the C library's
     * argon2_verify does internally). The salt travels to the CLI over argv,
     * so verification is exact for salts that are argv-transparent under the
     * platform's native encoding - ASCII, including every hex-form salt this
     * class generates; binary salt bytes >= 0x80 on a multi-byte encoding
     * platform will mismatch (use the library directly, as the C port does,
     * when that matters).
     */
    public static boolean argon2Verify(String encoded, String password) throws IOException, InterruptedException {
        Argon2Params params = parseArgon2(encoded); // validate format up front
        Options options = new Options();
        options.memory = (int) params.memory();
        options.iterations = (int) params.iterations();
        options.parallelism = (int) params.parallelism();
        // The CLI emits a fixed-length digest; keep the embedded length when
        // present so the comparison is byte-for-byte.
        options.hashLength = params.hash().isEmpty() ? DEFAULT_HASH_LENGTH : params.hash().length() / 2;
        options.salt = hexToBytes(params.salt());

        // Recompute with the type embedded in the string (-d / -i / -id).
        Normalized opts = normalizeOptions(options);
        List<String> command = new ArrayList<>(List.of(
                "argon2", new String(options.salt, StandardCharsets.ISO_8859_1),
                "-" + params.type().substring("argon2".length()), "-v", "13", "-e",
                "-m", Integer.toString((int) Math.round(Math.log(opts.memory()) / Math.log(2))),
                "-t", Integer.toString(opts.iterations()),
                "-p", Integer.toString(opts.parallelism()),
                "-l", Integer.toString(opts.hashLength())));
        String recomputed = run(command, password).trim();

        return params.hash().isEmpty() ? recomputed.equals(encoded.trim()) : digestMatches(recomputed, params.hash());
    }

    /** True when the recomputed PHC string's digest segment equals `wantHex`. */
    private static boolean digestMatches(String encoded, String wantHex) {
        String[] parts = encoded.split("\\$");
        return bytesToHex(phcBase64ToBytes(parts[parts.length - 1])).equals(wantHex);
    }
}

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 →