> 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/guides/website-password-hashing.md).

# Website: Password Hashing

Step-by-step guide for web developers - make your website register, log in, change and reset passwords with AXIOM's PBKDF2-SHA256 password hashes, with full Node.js and PHP code and test vectors.

AXIOM no longer stores passwords as plain text. `MEMB_INFO.memb__pwd` holds a **PBKDF2-SHA256 hash**. Any website, CMS, launcher or admin tool that creates accounts or checks passwords must use the same hash, or players get "wrong password" in game.

This guide shows how to change your website. It covers the algorithm, the four account pages (register, login, change password, reset password), and how to test your code.

For the server side (JoinServer settings, migration of old databases) see [Password Hashing](/docs/joinserver/password-hashing.md).

## What changed

|                               | Old website                        | New website                                  |
| ----------------------------- | ---------------------------------- | -------------------------------------------- |
| What is stored in `memb__pwd` | The password (`secret12`)          | A hash record (`$pbkdf2-sha256$i=15000$...`) |
| Column type                   | `varchar(10)`                      | `varchar(120)`                               |
| How login checks the password | SQL: `WHERE memb__pwd = @password` | Read the record, check it **in code**        |
| Register writes               | The password                       | `hashPassword(password)`                     |

{% hint style="danger" %}
The most common mistake: keeping a login query like `SELECT ... WHERE memb___id = @user AND memb__pwd = @pass`. It can never match a hash. Every such query must be replaced with "read the row, then verify in code" (see [Login](#login)).
{% endhint %}

## Before you start

1. **The column is widened.** Run the migration from [Password Hashing](/docs/joinserver/password-hashing.md#migration). After it, `memb__pwd` is `varchar(120)`.
2. **JoinServer is in PBKDF2 mode.** In `JoinServer.ini`:

   ```ini
   [JoinServerInfo]
   PasswordHashMode = 2
   LegacyPasswordMode = 0
   PasswordHashIterations = 15000
   ```
3. **Note the iteration count.** Your website must use the same `PasswordHashIterations` value (15000 by default).

## The algorithm

PBKDF2-HMAC-SHA256 is in the standard library of every web language. You do not need a DLL, an extension, or a WebZen MD5 port.

### Making a hash

1. Make a **salt**: 16 random bytes from a secure random source (`crypto.randomBytes`, `random_bytes`, `os.urandom`). Never `Math.random` or `rand()`. New salt for every hash, including password changes.
2. Run **PBKDF2** with:

   | Input         | Value                                                                       |
   | ------------- | --------------------------------------------------------------------------- |
   | Hash function | SHA-256                                                                     |
   | Password      | The password bytes exactly as typed (see [Password rules](#password-rules)) |
   | Salt          | The 16 bytes from step 1                                                    |
   | Iterations    | `PasswordHashIterations` (15000)                                            |
   | Output length | 32 bytes                                                                    |
3. **Base64** the salt and the 32-byte output. Standard base64 (`+` and `/`), with `=` padding.
4. Join them into the record:

   ```
   $pbkdf2-sha256$i=<iterations>$<base64 salt>$<base64 output>
   ```

   Example (93 characters):

   ```
   $pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ=
   ```
5. Store the record in `memb__pwd`.

### Checking a password

1. Read `memb__pwd` for the account.
2. If it does **not** start with `$pbkdf2-sha256$i=`, it is an old row. See [Old accounts](#old-accounts).
3. Split the rest on `$` into: iterations, base64 salt, base64 output.
4. Run PBKDF2 again with the typed password, the decoded salt and **the iterations from the record** (not your config value).
5. Compare the new 32 bytes with the decoded output using a **constant-time compare** (`timingSafeEqual`, `hash_equals`). Equal = correct password.

{% hint style="info" %}
Always use the iteration count written in the record. When the server raises `PasswordHashIterations`, old records still have the old count, and they must keep working until they are re-hashed.
{% endhint %}

## Password rules

These come from the game client and JoinServer. Enforce them on the register and change-password forms.

| Rule                                                       | Why                                                                                                       |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Max 10 characters**                                      | The game client sends at most 10 characters. A 12-character website password can never log in to the game |
| **Printable ASCII only** (letters, digits, normal symbols) | The game client sends its own character bytes. Your website hashes UTF-8. They only match for plain ASCII |
| **No trimming, no lower-casing**                           | Passwords are case-sensitive. Hash exactly what the user typed                                            |
| Username max 10 characters                                 | `memb___id` is `varchar(10)`                                                                              |

## Implementation

The same two helper functions serve every page: `hashPassword` and `verifyPassword`.

{% tabs %}
{% tab title="Node.js / TypeScript" %}
Built-in `crypto` only.

```ts
// password.ts
import crypto from "crypto";

const PREFIX = "$pbkdf2-sha256$i=";
const ITERATIONS = 15000;   // = PasswordHashIterations in JoinServer.ini

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

export interface VerifyResult {
  valid: boolean;
  rehash: boolean;   // true = write hashPassword(password) back to the row
}

export function verifyPassword(password: string, stored: string): VerifyResult {
  if (!stored.startsWith(PREFIX)) {
    // Old plain-text row (LegacyPasswordMode = 0)
    const valid = stored.length > 0 && password === stored;
    return { valid, rehash: valid };
  }

  const parts = stored.slice(PREFIX.length).split("$");
  if (parts.length !== 3) return { valid: false, rehash: false };

  const iterations = parseInt(parts[0], 10);
  const salt = Buffer.from(parts[1], "base64");
  const expected = Buffer.from(parts[2], "base64");
  if (!(iterations >= 1 && iterations <= 10_000_000) || expected.length !== 32) {
    return { valid: false, rehash: false };
  }

  const actual = crypto.pbkdf2Sync(password, salt, iterations, 32, "sha256");
  const valid = crypto.timingSafeEqual(actual, expected);
  return { valid, rehash: valid && iterations < ITERATIONS };
}

export function isValidGamePassword(password: string): boolean {
  return /^[\x21-\x7E]{4,10}$/.test(password);   // printable ASCII, 4-10 chars
}
```

{% endtab %}

{% tab title="PHP" %}
`hash_pbkdf2` is in PHP core.

```php
<?php
// password.php
const PW_PREFIX = '$pbkdf2-sha256$i=';
const PW_ITERATIONS = 15000;   // = PasswordHashIterations in JoinServer.ini

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

/** @return array{valid: bool, rehash: bool} */
function verifyPassword(string $password, string $stored): array {
    if (strpos($stored, PW_PREFIX) !== 0) {
        // Old plain-text row (LegacyPasswordMode = 0)
        $valid = $stored !== '' && hash_equals($stored, $password);
        return ['valid' => $valid, 'rehash' => $valid];
    }

    $parts = explode('$', substr($stored, strlen(PW_PREFIX)));
    if (count($parts) !== 3) return ['valid' => false, 'rehash' => false];

    $iterations = (int)$parts[0];
    $salt = base64_decode($parts[1], true);
    $expected = base64_decode($parts[2], true);
    if ($iterations < 1 || $iterations > 10000000 || $salt === false || $expected === false || strlen($expected) !== 32) {
        return ['valid' => false, 'rehash' => false];
    }

    $actual = hash_pbkdf2('sha256', $password, $salt, $iterations, 32, true);
    $valid = hash_equals($expected, $actual);
    return ['valid' => $valid, 'rehash' => $valid && $iterations < PW_ITERATIONS];
}

function isValidGamePassword(string $password): bool {
    return preg_match('/^[\x21-\x7E]{4,10}$/', $password) === 1;   // printable ASCII, 4-10 chars
}
```

{% endtab %}
{% endtabs %}

Python and C# versions of the same two functions are on the [Password Hashing](/docs/joinserver/password-hashing.md#for-web-developers) page.

### Register

Validate the password, hash it, insert the record.

{% tabs %}
{% tab title="Node.js (mssql)" %}

```ts
import sql from "mssql";
import { hashPassword, isValidGamePassword } from "./password";

if (!isValidGamePassword(password)) throw new Error("Password: 4-10 letters, digits or symbols");

await pool.request()
  .input("id", sql.VarChar(10), username)
  .input("pwd", sql.VarChar(120), hashPassword(password))   // 120, not 10
  .input("mail", sql.VarChar(50), email)
  .query(`INSERT INTO MEMB_INFO (memb___id, memb__pwd, memb_name, sno__numb, bloc_code, ctl1_code, mail_addr, appl_days)
          VALUES (@id, @pwd, @id, '1111111111111', '0', '0', @mail, GETDATE())`);
```

{% endtab %}

{% tab title="PHP (PDO sqlsrv)" %}

```php
if (!isValidGamePassword($password)) throw new Exception('Password: 4-10 letters, digits or symbols');

$st = $db->prepare("INSERT INTO MEMB_INFO (memb___id, memb__pwd, memb_name, sno__numb, bloc_code, ctl1_code, mail_addr, appl_days)
                    VALUES (?, ?, ?, '1111111111111', '0', '0', ?, GETDATE())");
$st->execute([$username, hashPassword($password), $username, $email]);
```

{% endtab %}
{% endtabs %}

Keep the other columns your current register page already fills. Only `memb__pwd` changes.

### Login

Read the record by username only, verify in code, and re-hash when asked.

{% tabs %}
{% tab title="Node.js (mssql)" %}

```ts
import { hashPassword, verifyPassword } from "./password";

const r = await pool.request()
  .input("id", sql.VarChar(10), username)
  .query("SELECT memb__pwd FROM MEMB_INFO WHERE memb___id = @id");

const stored = r.recordset[0]?.memb__pwd ?? "";
const check = verifyPassword(password, String(stored));

if (!check.valid) return fail("Wrong username or password");

if (check.rehash) {
  // Old plain-text row or old iteration count: upgrade it now. Never block the login on this.
  await pool.request()
    .input("id", sql.VarChar(10), username)
    .input("pwd", sql.VarChar(120), hashPassword(password))
    .query("UPDATE MEMB_INFO SET memb__pwd = @pwd WHERE memb___id = @id")
    .catch(() => {});
}

// ... start the session
```

{% endtab %}

{% tab title="PHP (PDO sqlsrv)" %}

```php
$st = $db->prepare('SELECT memb__pwd FROM MEMB_INFO WHERE memb___id = ?');
$st->execute([$username]);
$stored = (string)($st->fetchColumn() ?: '');

$check = verifyPassword($password, $stored);
if (!$check['valid']) fail('Wrong username or password');

if ($check['rehash']) {
    // Old plain-text row or old iteration count: upgrade it now.
    try {
        $db->prepare('UPDATE MEMB_INFO SET memb__pwd = ? WHERE memb___id = ?')
           ->execute([hashPassword($password), $username]);
    } catch (Throwable $e) { /* never block the login on this */ }
}

// ... start the session
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
Give the same error for "no such account" and "wrong password", so the login page cannot be used to find which usernames exist.
{% endhint %}

### Change password

1. Check the **current** password with `verifyPassword`, exactly like login.
2. Validate the new one with `isValidGamePassword`.
3. Write `hashPassword(newPassword)`:

```sql
UPDATE MEMB_INFO SET memb__pwd = @pwd WHERE memb___id = @id
```

Bind `@pwd` as `varchar(120)`.

### Reset password (e-mail link, admin panel)

There is no old password to check. Validate the new one and write `hashPassword(newPassword)` with the same `UPDATE`.

You **cannot** e-mail the user their old password any more - it is not stored. Replace any "send my password" feature with a reset link.

### Other places that touch passwords

Search your website code for `memb__pwd`. Fix every hit:

| You find                                                      | Change to                              |
| ------------------------------------------------------------- | -------------------------------------- |
| `WHERE ... memb__pwd = @pwd`                                  | Read the row, `verifyPassword` in code |
| `INSERT` / `UPDATE` with the plain password                   | `hashPassword(password)`               |
| `sql.VarChar(10)` or `varchar(10)` for the password parameter | `varchar(120)`                         |
| `[dbo].[fn_md5]`, `HASHBYTES('MD5', ...)`                     | `hashPassword` / `verifyPassword`      |
| Showing or e-mailing the password                             | Remove. Use a reset link               |
| Stored procedures that compare passwords                      | Move the check into website code       |

## Old accounts

After the migration, some rows still hold the old plain-text password until the player logs in once.

* **In game**: JoinServer checks the old value once, then rewrites it as a hash.
* **On the website**: the code above does the same. `verifyPassword` accepts the plain-text row and returns `rehash: true`, and the login page rewrites it.

Both sides upgrade accounts, so the database moves to hashes on its own.

When the server owner sets `LegacyPasswordMode = -1` (old rows rejected), remove the plain-text branch from `verifyPassword` too, so the website and the game agree.

{% hint style="warning" %}
**WebZen MD5 databases** (`LegacyPasswordMode = 1`): old rows hold 32 hex characters. Websites cannot check them. Those players must log in to the game once (JoinServer upgrades the row), or use the reset-password page. Your `verifyPassword` should return `valid: false` for these rows - it already does, because a 32-character hex value never equals a typed password of 10 characters or less.
{% endhint %}

## Test your code

Run your PBKDF2 call with these fixed inputs. The output must match **exactly**.

| Password   | Salt           | Iterations | Expected output (hex)                                              |
| ---------- | -------------- | ---------: | ------------------------------------------------------------------ |
| `password` | `salt` (ASCII) |          1 | `120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b` |
| `password` | `salt` (ASCII) |          2 | `ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43` |
| `password` | `salt` (ASCII) |       4096 | `c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a` |

JoinServer runs the same three checks at startup and refuses to start if any fails, so these values are what the server produces.

Full record test: `verifyPassword("Test1234", record)` must return valid for this record, and invalid for `Test1235`:

```
$pbkdf2-sha256$i=15000$AAECAwQFBgcICQoLDA0ODw==$0D2Qc/Uk7HghEtNbPYBILfYyEF8V+1a3qqW2pDlcxuQ=
```

(Salt = bytes `00 01 02 ... 0F`.)

**Last test - in game:**

1. Register a new account on the website.
2. In SQL, check `memb__pwd` starts with `$pbkdf2-sha256$i=15000$` and is 93 characters long.
3. Log in with the game client. It must work.
4. Change the password on the website, log in to the game with the new one.

## Common Issues

{% hint style="warning" %}
**Website login works, game login says wrong password.** The password is longer than 10 characters or has non-ASCII characters. Enforce [Password rules](#password-rules).
{% endhint %}

{% hint style="warning" %}
**Accounts made on the website can never log in anywhere.** The password parameter is bound as `varchar(10)`, so the record is cut to `$pbkdf2-sh`. Bind it as `varchar(120)`. Check: `SELECT memb___id FROM MEMB_INFO WHERE LEN(memb__pwd) BETWEEN 1 AND 20 AND memb__pwd LIKE '$%'`.
{% endhint %}

{% hint style="warning" %}
**Game login works, website login always fails.** The website still compares in SQL (`WHERE memb__pwd = @pwd`). See [Login](#login).
{% endhint %}

{% hint style="warning" %}
**"String or binary data would be truncated" on register.** The migration was not run; `memb__pwd` is still `varchar(10)`. See [Before you start](#before-you-start).
{% endhint %}

{% hint style="warning" %}
**Logins are slow under load.** 15000 iterations takes a few milliseconds per check. That is on purpose. Rate-limit the login and register pages; do not lower the iteration count below the JoinServer value.
{% endhint %}

## Related pages

* [Password Hashing](/docs/joinserver/password-hashing.md) - server settings, database migration, Python and C# code
* [JoinServer.ini](/docs/joinserver/joinserver-ini.md) - `PasswordHashMode`, `LegacyPasswordMode`, `PasswordHashIterations`, `CaseSensitive`
