Password Hashing

How AXIOM stores account passwords, and how to make a website, CMS, launcher or admin tool speak the same format.

Why this exists#

Classic MU emulators store MEMB_INFO.memb__pwd in one of two ways, and both are indefensible in 2026:

  • Plain text in a varchar(10) column. Anyone with a database backup has every password.
  • WebZen "MD5", which is RFC 1321 MD5 with its initialisation vector swapped for one of 256 fixed values picked by a checksum of the account name. It is not standard MD5, so HASHBYTES('MD5', @pwd) does not reproduce it, no language's standard library reproduces it, and every website that wanted to create accounts had to ship a compiled WebZen MD5 binary. On top of that it is unsalted MD5 with 256 possible variants — a leaked table is cracked in minutes.

AXIOM adds a third scheme, PBKDF2-HMAC-SHA256, and keeps the two legacy ones selectable so existing databases keep working while they migrate.

The three modes#

Set in JoinServer.ini:

[JoinServerInfo]
PasswordHashMode = 2
LegacyPasswordMode = 0
PasswordHashIterations = 15000
PasswordHashModeSchemeColumn typeReproducible outside the server?
0Plain textvarchar(10)Trivially — there is nothing to reproduce
1WebZen MD5varbinary(16)Only with a port of WebZen's modified MD5
2PBKDF2-HMAC-SHA256varchar(120)Yes — standard library of any language

LegacyPasswordMode only matters when PasswordHashMode = 2. It tells JoinServer how to read accounts that have not been re-hashed yet: 0 = plain text, 1 = WebZen MD5, -1 = reject them.

PasswordHashIterations is the PBKDF2 work factor for newly written hashes. Raising it later is safe — each record stores the count it was made with, and accounts are re-hashed at the higher count on their next login.

Record format#

$pbkdf2-sha256$i=15000$Xk9dQ1RwT3ZzZzRuTg==$7FaZq3B1eF0mQfE2sT8yV5cKpNw1rXjLh4dGm9uYbA0=
└──── algorithm ────┘└ iters ┘└─ base64 salt ─┘└──────── base64 digest ────────┘
  • 16-byte salt, generated per account from the OS CSPRNG (CryptGenRandom).
  • 32-byte digest, PBKDF2-HMAC-SHA256.
  • 93 ASCII characters total for the default parameters. Only base64 characters plus $ and = — safe to embed in any SQL literal.
  • Self-describing: the parameters travel with the hash, so the algorithm can be upgraded without a schema change or a flag day.

A stored value that begins with $ is a hash. Anything else is a legacy row.

Migration#

Passwords reach JoinServer in the clear from the client (the client↔server link is separately encrypted), so migration needs no password resets and no downtime.

  1. Run the SQL migration once:

    server\migrations\v1.0.7_widen_memb_pwd_for_pbkdf2.sql
    

    Run it against the muonline database in SQL Server Management Studio, as described in Applying Updates.

    For a plain-text database this widens memb__pwd to varchar(120). For a WebZen-MD5 database it also converts the column from varbinary(16) to the 32-character hex form of the same digest, so one column can hold both old and new records.

  2. Set PasswordHashMode = 2, and LegacyPasswordMode to whatever the table currently holds (0 for plain text, 1 for WebZen MD5).

  3. Restart JoinServer. Each account verifies against the legacy value once, is immediately rewritten as PBKDF2, and uses the new path from then on. The server logs [Password] upgraded account (name) to PBKDF2.

  4. Watch the tail of the migration:

    SELECT
        SUM(CASE WHEN memb__pwd LIKE '$pbkdf2-sha256$%' THEN 1 ELSE 0 END) AS Migrated,
        SUM(CASE WHEN memb__pwd LIKE '$pbkdf2-sha256$%' THEN 0 ELSE 1 END) AS Legacy
    FROM MEMB_INFO;
    
  5. When Legacy is only dormant accounts, set LegacyPasswordMode = -1. Those accounts can then only be recovered through a password reset on the website.


For web developers#

Your site never sees a hash it has to understand — it just has to produce and check the same record format. No DLL, no native addon, no WebZen MD5 port.

Rules#

  1. Match the modes. Your site's hashing mode must equal PasswordHashMode in JoinServer.ini. Writing a plain-text password into a database whose JoinServer is on mode 2 still works while LegacyPasswordMode = 0, but breaks the moment it is set to -1.
  2. Write the record, not the password. memb__pwd is varchar(120). Bind the parameter as a 120-char varchar — a VarChar(10) binding silently truncates the hash and locks the account out.
  3. Always use parameters. Never interpolate a username or password into SQL.
  4. Re-hash on login. If a login succeeds against a legacy plain-text value, rewrite the row as PBKDF2 in the same request. Your site and the game server then migrate accounts together.
  5. Never lower the iteration count below the server's PasswordHashIterations. Higher is fine; the server accepts any count the record declares.

Node.js / TypeScript#

Built-in crypto only.

import crypto from "crypto";

const PREFIX = "$pbkdf2-sha256$i=";
const ITERATIONS = 15000;   // must match PasswordHashIterations

export function hashPassword(password: string): string {
  const salt = crypto.randomBytes(16);
  const digest = crypto.pbkdf2Sync(password, salt, ITERATIONS, 32, "sha256");
  return `${PREFIX}${ITERATIONS}$${salt.toString("base64")}$${digest.toString("base64")}`;
}

export function verifyPassword(password: string, stored: string): boolean {
  if (!stored.startsWith(PREFIX)) return false;            // legacy row
  const [iters, salt, digest] = stored.slice(PREFIX.length).split("$");
  const computed = crypto.pbkdf2Sync(
    password, Buffer.from(salt, "base64"), parseInt(iters, 10), 32, "sha256"
  );
  return crypto.timingSafeEqual(computed, Buffer.from(digest, "base64"));
}

Writing it back with mssql:

await db.request()
  .input("membId", sql.VarChar(10), username)
  .input("password", sql.VarChar(120), hashPassword(password))   // NOT VarChar(10)
  .query("UPDATE MEMB_INFO SET memb__pwd = @password WHERE memb___id = @membId");

PHP#

hash_pbkdf2 is in core, no extension needed.

const PREFIX = '$pbkdf2-sha256$i=';
const ITERATIONS = 15000;

function hashPassword(string $password): string {
    $salt = random_bytes(16);
    $digest = hash_pbkdf2('sha256', $password, $salt, ITERATIONS, 32, true);
    return PREFIX . ITERATIONS . '$' . base64_encode($salt) . '$' . base64_encode($digest);
}

function verifyPassword(string $password, string $stored): bool {
    if (strpos($stored, PREFIX) !== 0) return false;          // legacy row
    [$iters, $salt, $digest] = explode('$', substr($stored, strlen(PREFIX)));
    $computed = hash_pbkdf2('sha256', $password, base64_decode($salt), (int)$iters, 32, true);
    return hash_equals(base64_decode($digest), $computed);
}

Python#

hashlib only.

import base64, hashlib, hmac, os

PREFIX = "$pbkdf2-sha256$i="
ITERATIONS = 15000

def hash_password(password: str) -> str:
    salt = os.urandom(16)
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS, 32)
    return f"{PREFIX}{ITERATIONS}${base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}"

def verify_password(password: str, stored: str) -> bool:
    if not stored.startswith(PREFIX):
        return False                                          # legacy row
    iters, salt, digest = stored[len(PREFIX):].split("$")
    computed = hashlib.pbkdf2_hmac(
        "sha256", password.encode(), base64.b64decode(salt), int(iters), 32
    )
    return hmac.compare_digest(computed, base64.b64decode(digest))

C# / .NET#

const string Prefix = "$pbkdf2-sha256$i=";
const int Iterations = 15000;

static string HashPassword(string password)
{
    var salt = RandomNumberGenerator.GetBytes(16);
    var digest = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, 32);
    return $"{Prefix}{Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(digest)}";
}

static bool VerifyPassword(string password, string stored)
{
    if (!stored.StartsWith(Prefix)) return false;             // legacy row
    var parts = stored.Substring(Prefix.Length).Split('$');
    var digest = Convert.FromBase64String(parts[2]);
    var computed = Rfc2898DeriveBytes.Pbkdf2(
        password, Convert.FromBase64String(parts[1]), int.Parse(parts[0]), HashAlgorithmName.SHA256, 32);
    return CryptographicOperations.FixedTimeEquals(computed, digest);
}

T-SQL#

PBKDF2 cannot be expressed as a HASHBYTES call — it is 15 000 chained HMACs. Hash in application code and pass the finished record as a parameter. HASHBYTES is only useful here for reporting queries such as counting migrated accounts.

AXIOM CMS#

The bundled CMS already implements all of this in src/lib/password.ts. Configure it in .env:

PASSWORD_HASH_MODE=pbkdf2          # must match PasswordHashMode = 2
PASSWORD_LEGACY_MODE=plaintext     # must match LegacyPasswordMode = 0
PASSWORD_HASH_ITERATIONS=15000     # must match PasswordHashIterations

verifyPasswordEx() returns { valid, rehash }; the login route rewrites the row when rehash is true, exactly like JoinServer does. webzen_md5 is accepted as a mode name but throws — the CMS cannot produce those digests, and such accounts start working in the CMS once they have logged into the game once and been upgraded.

Constraints to keep in mind#

  • The game client's login packet caps the password at 10 characters. Enforcing a longer minimum on the website creates accounts nobody can log in with.
  • memb___id is varchar(10); usernames are limited the same way.
  • Password comparison at the game server is case-sensitive in every mode.

Last updated October 1, 2026