> For the complete documentation index, see [llms.txt](https://axiomemu.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://axiomemu.gitbook.io/docs/joinserver/password-hashing.md).

# 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`:

```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.

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](/docs/guides/applying-updates.md).

   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:

   ```sql
   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.

{% hint style="warning" %}
Do not skip step 1. If `memb__pwd` is still `varchar(10)` the upgrade `UPDATE` fails, JoinServer logs `could not store upgraded hash`, and every login keeps falling back to the legacy comparison forever.
{% endhint %}

***

## 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.

```ts
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`:

```ts
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.

```php
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.

```python
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

```csharp
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`:

```ini
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.

### Related pages

* [JoinServer.ini](/docs/joinserver/joinserver-ini.md) — all settings, including `CaseSensitive` interaction
* [Overview](/docs/joinserver/joinserver.md) — JoinServer role and startup order
