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 = 15000PasswordHashMode
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.
Run the SQL migration once:
Run 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:
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.
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___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
CaseSensitiveinteractionOverview — JoinServer role and startup order
Last updated