Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ const isMatch = await security.password.compare('super-secret', hash);
- `security.string`: safe slug-style string normalization.
- `security.identifier`: legacy identifier helper (`uid`, deprecated).

### Password reset tokens

`security.tokens.resetToken.generateHash()` and `compare()` require a
`passwordHash` option containing the user's existing stored password hash.
Do not pass a plaintext password. The stored hash invalidates reset tokens
after a password change; these helpers do not hash passwords for storage.

```js
const token = security.tokens.resetToken.generateHash({
expires,
email,
dbHash,
passwordHash: storedPasswordHash,
});

const result = security.tokens.resetToken.compare({
token,
dbHash,
passwordHash: storedPasswordHash,
});
```

This option was renamed from `password` to `passwordHash`. Callers must update
both methods when upgrading. The token format is unchanged, so existing reset
tokens remain valid with the same stored hash, site secret, and expiry.

# Copyright & License

Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).
9 changes: 4 additions & 5 deletions packages/security/lib/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,14 @@ module.exports.resetToken = {
const expires = options.expires;
const email = options.email;
const dbHash = options.dbHash;
const password = options.password;
const passwordHash = options.passwordHash;
let text = '';

hash.update(String(expires));
hash.update(email.toLocaleLowerCase());
// Reset tokens are not password storage; the current password hash is
// mixed in only to invalidate old tokens after password changes.
// codeql[js/insufficient-password-hash]
hash.update(password);
hash.update(passwordHash);
hash.update(String(dbHash));

text += [expires, email, hash.digest('base64')].join('|');
Expand Down Expand Up @@ -84,7 +83,7 @@ module.exports.resetToken = {
const tokenToCompare = options.token;
const parts = exports.resetToken.extract({ token: tokenToCompare });
const dbHash = options.dbHash;
const password = options.password;
const passwordHash = options.passwordHash;
let generatedToken;
let diff = 0;
let i;
Expand All @@ -108,7 +107,7 @@ module.exports.resetToken = {
email: parts.email,
expires: parts.expires,
dbHash: dbHash,
password: password,
passwordHash: passwordHash,
});

if (tokenToCompare.length !== generatedToken.length) {
Expand Down
61 changes: 43 additions & 18 deletions packages/security/test/tokens.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const assert = require('assert/strict');
const crypto = require('crypto');
const security = require('../');

const passwordHash = '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsW';
const differentPasswordHash = '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsX';

describe('Utils: tokens', function () {
it('covers default options branches for token helpers', function () {
assert.throws(() => security.tokens.generateFromContent());
Expand Down Expand Up @@ -45,14 +48,36 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: 'password',
passwordHash: passwordHash,
dbHash: dbHash,
});

assert.notEqual(token, undefined);
assert.equal(token.length > 0, true);
});

it('preserves reset tokens generated before the option rename', function () {
const expires = 4102444800000;
const email = 'Mixed+Case@example.com';
const dbHash = 'test-site-secret';
// Generated by the previous API with the same stored password hash.
const existingToken =
'NDEwMjQ0NDgwMDAwMHxNaXhlZCtDYXNlQGV4YW1wbGUuY29tfGxQbk0vVmpEQXJpdk5XSWZyU1JUTnRtK2ZEdG5TMTlkLzhvejQ4RWlsOFU9';

assert.equal(
security.tokens.resetToken.generateHash({ expires, email, dbHash, passwordHash }),
existingToken,
);
assert.deepEqual(
security.tokens.resetToken.compare({ token: existingToken, dbHash, passwordHash }),
{ correct: true },
);
assert.deepEqual(security.tokens.resetToken.extract({ token: existingToken }), {
expires,
email,
});
});

it('compare: success', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = crypto.randomUUID();
Expand All @@ -62,21 +87,21 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '12345678',
passwordHash: passwordHash,
});

assert.equal(tokenIsCorrect.correct, true);
assert.equal(tokenIsCorrect.reason, undefined);
});

it('compare: error from invalid password', function () {
it('compare: error from changed password hash', function () {
const expires = Date.now() + 60 * 1000;
const dbHash = crypto.randomUUID();
let token;
Expand All @@ -85,14 +110,14 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: expires,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '123456',
passwordHash: differentPasswordHash,
});

assert.equal(tokenIsCorrect.correct, false);
Expand All @@ -108,14 +133,14 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: invalidDate,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '123456',
passwordHash: differentPasswordHash,
});

assert.equal(tokenIsCorrect.correct, false);
Expand All @@ -131,14 +156,14 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: 'test1@ghost.org',
expires: dateInThePast,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '123456',
passwordHash: differentPasswordHash,
});

assert.equal(tokenIsCorrect.correct, false);
Expand All @@ -155,7 +180,7 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

Expand All @@ -165,7 +190,7 @@ describe('Utils: tokens', function () {

assert.equal(parts.email, email);
assert.equal(parts.expires, expires);
assert.equal(parts.password, undefined);
assert.equal(parts.passwordHash, undefined);
assert.equal(parts.dbHash, undefined);
});

Expand All @@ -179,7 +204,7 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsW',
passwordHash: passwordHash,
dbHash: dbHash,
});

Expand All @@ -189,7 +214,7 @@ describe('Utils: tokens', function () {

assert.equal(parts.email, email);
assert.equal(parts.expires, expires);
assert.equal(parts.password, undefined);
assert.equal(parts.passwordHash, undefined);
assert.equal(parts.dbHash, undefined);
});

Expand All @@ -211,7 +236,7 @@ describe('Utils: tokens', function () {
token = security.tokens.resetToken.generateHash({
email: email,
expires: expires,
password: '12345678',
passwordHash: passwordHash,
dbHash: dbHash,
});

Expand All @@ -230,7 +255,7 @@ describe('Utils: tokens', function () {
tokenIsCorrect = security.tokens.resetToken.compare({
token: token,
dbHash: dbHash,
password: '12345678',
passwordHash: passwordHash,
});

assert.equal(tokenIsCorrect.correct, true);
Expand All @@ -243,7 +268,7 @@ describe('Utils: tokens', function () {
const token = security.tokens.resetToken.generateHash({
email: 'test4@ghost.org',
expires,
password: '12345678',
passwordHash: passwordHash,
dbHash,
});

Expand All @@ -252,7 +277,7 @@ describe('Utils: tokens', function () {
const tokenIsCorrect = security.tokens.resetToken.compare({
token: mismatchedLengthToken,
dbHash,
password: '12345678',
passwordHash: passwordHash,
});

assert.equal(tokenIsCorrect.correct, false);
Expand Down
Loading