For the complete documentation index, see llms.txt. This page is also available as Markdown.

Password Hashing

How AXIOM stores account passwords (PBKDF2-HMAC-SHA256), how to migrate a legacy plaintext or WebZen-MD5 database, and how a website or CMS produces the same hashes without any compiled dependency.

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

  • 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:

    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:

  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.

Writing it back with mssql:

PHP

hash_pbkdf2 is in core, no extension needed.

Python

hashlib only.

C# / .NET

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:

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