verifyPassword
Checks a password against a stored scrypt hash in constant time, recomputing it with the hash's own parameters.
import { verifyPassword } from "@meekojs/meeko/password";verifyPassword(password, hash) checks a password against a scrypt$<N>$<r>$<p>$<sal>$<hash> hash: one from hashPassword or an older one with parameters within Meeko's limits. It recomputes the hash with the stored parameters and compares it in constant time.
Import it from @meekojs/meeko/password, only on the server (Node, Bun and Deno).
Usage#
// server/rpc/auth/login.ts
import { ApiError, defineRpc } from "@meekojs/meeko/server";
import { verifyPassword } from "@meekojs/meeko/password";
import { loginInput, loginOutput } from "../../../shared/schemas/auth";
const DUMMY_HASH = "scrypt$131072$8$1$…"; // any hash, computed once with meeko auth hash
export default defineRpc({
input: loginInput,
output: loginOutput,
origin: "required",
rateLimit: { limit: 5, window: "5m" },
handler: async ({ input, context }) => {
const user = await context.services.db.users.byEmail(input.email);
const valid = await verifyPassword(input.password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !valid) throw new ApiError("INVALID_CREDENTIALS", 401);
await context.session.create({ userId: user.id });
return { ok: true };
},
});Comparing against a fixed hash when the user does not exist makes the response take the same time, without revealing which email addresses have an account.
Type#
function verifyPassword(password: string, hash: string): Promise<boolean>Parameters#
| Parameter | Type | Default | Description |
|---|---|---|---|
password |
string |
required | The received password. |
hash |
string |
required | The stored hash. |
Return value#
A promise with true if the password matches and false otherwise. An empty password or one exceeding 4096 bytes returns false without computing anything.
Throws TypeError: Invalid password hash: expected scrypt$<N>$<r>$<p>$<salt>$<hash> within Meeko's limits if the hash has the wrong format or its parameters are outside the limits (N a power of two between 2^10 and 2^20, r up to 32, p up to 16). This is a configuration failure, not an incorrect password.
Examples#
An administration account in .env#
// server/api/admin/login.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { verifyPassword } from "@meekojs/meeko/password";
import { adminLogin, okResult } from "../../../shared/schemas/admin";
export default defineApiRoute({
origin: "required",
rateLimit: { limit: 5, window: "15m" },
body: adminLogin,
response: okResult,
handler: async ({ body, context }) => {
const valid = await verifyPassword(body.password, process.env.ADMIN_PASSWORD_HASH ?? "");
if (!valid || body.username !== process.env.ADMIN_USERNAME) throw new ApiError("INVALID_CREDENTIALS", 401);
await context.session.create({ user: body.username, role: "owner" });
return { ok: true as const };
},
});If ADMIN_PASSWORD_HASH is missing, verifyPassword throws TypeError for the empty hash: the configuration failure appears on the first attempt, instead of silently rejecting every password.
Update the hash after login#
if (await verifyPassword(input.password, user.passwordHash)) {
if (needsRehash(user.passwordHash)) {
await db.users.update(user.id, { passwordHash: await hashPassword(input.password) });
}
}Common mistakes#
A hash in another format#
await verifyPassword(input.password, "$2b$10$…"); // a bcrypt hashTypeError: Invalid password hash: expected scrypt$<N>$<r>$<p>$<salt>$<hash> within Meeko's limits. Migrate those accounts by verifying with their library and storing a new hash with hashPassword after the next login.
const valid = user.passwordHash.startsWith("scrypt$")
? await verifyPassword(input.password, user.passwordHash)
: await bcrypt.compare(input.password, user.passwordHash);Respond differently if the user does not exist#
Returning early when there is no user makes the response faster and reveals which email addresses have an account, without any error.
const user = await findUser(input.email);
if (!user) throw new ApiError("UNKNOWN_EMAIL", 404);const valid = await verifyPassword(input.password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !valid) throw new ApiError("INVALID_CREDENTIALS", 401);Expect it to throw for an incorrect password#
verifyPassword only throws for a malformed hash: an incorrect, empty or overly long password returns false.
try { await verifyPassword(input.password, hash); } catch { throw new ApiError("INVALID_CREDENTIALS", 401); }if (!(await verifyPassword(input.password, hash))) throw new ApiError("INVALID_CREDENTIALS", 401);Notes#
- Security. Limit attempts with
rateLimitand requireorigin: "required"on login. Rotate the session on login (session.createcreates a new one). - Cost. Each check costs the same as a
hashPassword: about 128 MiB and hundreds of milliseconds. - Runtimes. Only in Node, Bun and Deno; in a component, the import warns with
MEEK109.