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
PasswordHashMode | Scheme | Column type | Reproducible outside the server? |
|---|---|---|---|
0 | Plain text | varchar(10) | Trivially — there is nothing to reproduce |
1 | WebZen MD5 | varbinary(16) | Only with a port of WebZen's modified MD5 |
2 | PBKDF2-HMAC-SHA256 | varchar(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.
-
Run the SQL migration once:
server\migrations\v1.0.7_widen_memb_pwd_for_pbkdf2.sqlRun it against the
muonlinedatabase in SQL Server Management Studio, as described in Applying Updates.For a plain-text database this widens
memb__pwdtovarchar(120). For a WebZen-MD5 database it also converts the column fromvarbinary(16)to the 32-character hex form of the same digest, so one column can hold both old and new records. -
Set
PasswordHashMode = 2, andLegacyPasswordModeto whatever the table currently holds (0for plain text,1for WebZen MD5). -
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. -
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; -
When
Legacyis only dormant accounts, setLegacyPasswordMode = -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#
- Match the modes. Your site's hashing mode must equal
PasswordHashModeinJoinServer.ini. Writing a plain-text password into a database whose JoinServer is on mode2still works whileLegacyPasswordMode = 0, but breaks the moment it is set to-1. - Write the record, not the password.
memb__pwdisvarchar(120). Bind the parameter as a 120-char varchar — aVarChar(10)binding silently truncates the hash and locks the account out. - Always use parameters. Never interpolate a username or password into SQL.
- 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.
- 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___idisvarchar(10); usernames are limited the same way.- Password comparison at the game server is case-sensitive in every mode.
Related pages#
- Website: Password Hashing — step-by-step website guide (register, login, change, reset)
- JoinServer.ini — all settings, including
CaseSensitiveinteraction - Overview — JoinServer role and startup order