Quick answer
Generate password reset tokens with at least 256 bits from a cryptographically secure random source. Send the raw token only through the user’s approved recovery channel, but store only a SHA-256 digest with the user ID, purpose, expiry, and single-use state. On redemption, hash the presented token, atomically consume a matching unexpired record, update the password with the application’s slow password hasher, and invalidate relevant sessions.
Return the same public response whether an account exists, rate-limit both issuance and redemption, build links from a fixed trusted origin, and never log raw tokens. Java should use SecureRandom; Node.js should use randomBytes from node:crypto.
Threat model
A reset token is a short-lived bearer credential with power to replace a password. The workflow must resist:
- account enumeration through messages, status codes, or timing;
- token guessing and brute force;
- token theft from databases, logs, analytics, email scanners, or referrers;
- replay after successful use;
- multiple concurrent redemptions;
- denial of service through repeated reset requests;
- host-header injection that creates attacker-controlled links;
- session persistence after an account takeover.
The workflow cannot guarantee security when the recovery mailbox is compromised. High-risk applications may require additional recovery assurance, manual review, or a cooling-off period. Security questions are not a strong sole factor because answers are often discoverable.
Lifecycle and data model
Use two endpoints:
POST /password-reset/requests
POST /password-reset/redemptions
Issuance:
normalize identifier
return a neutral response
look up eligible account internally
apply account and network rate limits
invalidate older active reset records if policy requires
generate random token
store token digest, user, purpose, expiry
send fixed-origin HTTPS link through approved channel
Redemption:
decode token with strict length limits
hash token
atomically consume matching unexpired record
validate the new password
store a slow password hash
invalidate sessions and sensitive recovery state
send an account notification
record sanitized security events
A record can contain:
| Field | Purpose |
|---|---|
token_digest | Lookup without storing the bearer token. |
user_id | Account being recovered. |
purpose | Prevent use in a different token workflow. |
expires_at | Short validity boundary. |
consumed_at | Single-use audit state. |
created_at | Operational and abuse analysis. |
Add a unique index on the digest. Redemption must combine the “unused and unexpired” condition with the update in one transaction or atomic statement.
Generate and encode tokens
Thirty-two random bytes provide 256 bits of entropy:
raw bytes -> base64url without padding -> email link
raw token text -> SHA-256 -> stored digest
Base64url avoids /, +, and padding problems in URLs. Apply a strict maximum input length before decoding or hashing attacker-controlled text.
SHA-256 is appropriate for hashing a high-entropy random token because offline guessing is infeasible. It is not appropriate for human passwords; passwords need a slow, salted password hash such as Argon2id, scrypt, bcrypt, or PBKDF2 under current policy.
Java with Spring Boot
Generate a raw token and digest:
@Service
class PasswordResetService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Duration LIFETIME = Duration.ofMinutes(20);
private final ResetTokenRepository tokens;
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final ResetMailSender mailSender;
private final Clock clock;
String newToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
byte[] digest(String token) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(token.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(exception);
}
}
}
Issue a reset without changing the public response:
@Transactional
public void request(String submittedEmail, RequestContext context) {
String email = normalizeEmail(submittedEmail);
rateLimiter.check(context.networkKey(), email);
users.findActiveByEmail(email).ifPresent(user -> {
String raw = newToken();
Instant expiresAt = clock.instant().plus(LIFETIME);
tokens.replaceActiveForUser(
user.id(), digest(raw), "password-reset", expiresAt
);
mailSender.sendReset(
user.email(),
"https://app.example.test/reset-password?token=" +
URLEncoder.encode(raw, StandardCharsets.UTF_8)
);
});
}
The controller always returns an accepted neutral result:
@PostMapping("/password-reset/requests")
ResponseEntity<Map<String, String>> request(@RequestBody ResetRequest body) {
resetService.request(body.email(), requestContext());
return ResponseEntity.accepted().body(Map.of(
"message", "If the account is eligible, reset instructions will be sent."
));
}
Redeem atomically:
@Transactional
public void redeem(String rawToken, char[] newPassword) {
if (!TOKEN_PATTERN.matcher(rawToken).matches()) {
throw new InvalidResetTokenException();
}
Instant now = clock.instant();
ResetGrant grant = tokens.consume(
digest(rawToken), "password-reset", now
).orElseThrow(InvalidResetTokenException::new);
passwordPolicy.requireValid(newPassword);
users.updatePassword(
grant.userId(),
passwordEncoder.encode(CharBuffer.wrap(newPassword))
);
sessions.revokeAllForUser(grant.userId());
audit.record("password_reset_succeeded", grant.userId());
Arrays.fill(newPassword, '\0');
}
The repository’s consume operation should perform an update with consumed_at IS NULL AND expires_at > now, returning the affected record only when exactly one row changes.
Node.js with Express
Generate token material with the standard library:
import {
createHash,
randomBytes
} from "node:crypto";
import express from "express";
const app = express();
app.use(express.json({ limit: "16kb" }));
const newToken = () => randomBytes(32).toString("base64url");
const digest = (token) =>
createHash("sha256").update(token, "ascii").digest();
Issue asynchronously but keep the response neutral:
app.post("/password-reset/requests", async (req, res) => {
const email = normalizeEmail(String(req.body.email ?? ""));
await requestLimiter.check(req.ip, email);
const user = await users.findEligibleByEmail(email);
if (user) {
const raw = newToken();
await resetTokens.replaceActive({
userId: user.id,
digest: digest(raw),
purpose: "password-reset",
expiresAt: new Date(Date.now() + 20 * 60_000)
});
await resetQueue.enqueue({
destination: user.email,
link: `https://app.example.test/reset-password?token=${
encodeURIComponent(raw)
}`
});
}
res.status(202).json({
message: "If the account is eligible, reset instructions will be sent."
});
});
Redeem:
app.post("/password-reset/redemptions", async (req, res) => {
const raw = String(req.body.token ?? "");
const password = String(req.body.newPassword ?? "");
if (!/^[A-Za-z0-9_-]{43}$/.test(raw)) {
return res.status(400).json({ error: "invalid_or_expired_token" });
}
const result = await database.transaction(async (tx) => {
const grant = await tx.resetTokens.consume({
digest: digest(raw),
purpose: "password-reset",
now: new Date()
});
if (!grant) return null;
passwordPolicy.requireValid(password);
await tx.users.updatePassword(
grant.userId,
await passwordHasher.hash(password)
);
await tx.sessions.revokeAll(grant.userId);
return grant.userId;
});
if (!result) {
return res.status(400).json({ error: "invalid_or_expired_token" });
}
await audit.record("password_reset_succeeded", { userId: result });
res.status(204).end();
});
Do not use an in-memory token map in a multi-instance deployment. Use a database transaction with a unique digest and a conditional consume operation.
Email and browser safety
Build the reset URL from a fixed configured origin, never from the incoming Host or forwarded host header. Use HTTPS. The reset page should avoid third-party analytics and assets that might receive the token through URLs or referrers. Set a restrictive referrer policy such as no-referrer.
After the page loads, exchange the URL token for a short-lived server-side recovery session or remove it from the address bar with history.replaceState. Do not place the token in localStorage.
Email scanners may open links. A GET should display the reset form, not consume the token or change the password. Consumption belongs to the explicit POST that includes the new password.
Enumeration resistance
Use the same status and message for existing, missing, disabled, and federated-only accounts. Aim for similar response timing, but do not add arbitrary sleeps that attackers can use for resource exhaustion. Queue eligible mail work and keep public processing bounded.
Rate-limit by a privacy-preserving combination of network, submitted identifier, account, and device or challenge signal. Do not lock an account because someone requested resets; that creates an account denial-of-service tool.
Avoid revealing account existence later through different validation errors. The reset form should report the token as invalid or expired without distinguishing missing account, used token, or exact expiry.
Session and account consequences
After success:
- revoke existing sessions or offer a risk-appropriate explicit choice;
- revoke refresh tokens where supported;
- rotate security stamps or password version counters;
- notify the account through a trusted channel;
- preserve MFA unless the product has a separate, stronger recovery process;
- do not automatically log the user in unless the threat model and session design justify it.
Password reset must not silently disable MFA, change the recovery email, or issue new API keys.
Failure handling and audit events
Useful events include:
password_reset_requested
password_reset_rate_limited
password_reset_invalid_token
password_reset_succeeded
password_reset_sessions_revoked
Store user ID only after it is safely known, request correlation ID, coarse network risk data, timestamp, and outcome. Never store the raw token, token-bearing URL, new password, session cookie, or email body.
Mail delivery failure should not change the public issuance response. Monitor it internally and expire unsent records according to policy.
Tests that matter
- Both known and unknown email requests return the same status and message.
- Generated tokens decode to 32 random bytes and do not repeat in a large test sample.
- The database stores the digest, not raw text.
- Expired, malformed, missing, and already used tokens receive one public error.
- Two concurrent redemptions produce exactly one password update.
- A successful redemption stores a slow password hash and revokes sessions.
- GET does not consume the token.
- A reset URL always uses the configured origin despite hostile host headers.
- Issuance and redemption rate limits operate independently.
- Audit and application logs contain no raw token or password.
Use an injected clock and random source in deterministic unit tests, while retaining integration tests against the real platform cryptography.
Common mistakes
Storing raw tokens. A database read becomes immediate account takeover. Store a digest.
Using UUIDs without an explicit cryptographic guarantee. Generate 32 bytes with SecureRandom or randomBytes.
Long-lived or reusable tokens. Keep expiry short and consume atomically.
Different responses for unknown accounts. This creates enumeration.
Reset links built from Host. An attacker can cause links to point to their domain.
Logging the query string. Reset tokens commonly appear there.
Using SHA-256 for passwords. Fast hashing is for random token lookup; passwords require a slow password hasher.
Consuming on GET. Email scanners can invalidate the link before the user acts.
Practical checklist
- Generate at least 256 random bits.
- Encode with base64url and enforce a strict input shape.
- Store only a digest with user, purpose, and expiry.
- Atomically consume a single-use record.
- Return neutral issuance and redemption messages.
- Rate-limit network, identifier, account, and token attempts.
- Use a fixed HTTPS application origin.
- Keep reset pages free of token-leaking third parties.
- Hash the new password with the approved slow hasher.
- Revoke appropriate sessions and refresh tokens.
- Notify the user after success.
- Audit outcomes without secrets.
- Test concurrency, expiry, enumeration, and hostile host headers.
Frequently asked questions
Should reset tokens be JWTs?
Usually an opaque random token with server-side state is simpler to revoke, consume once, and audit. JWTs add parsing and key-management risks and still need state for reliable single use.
Why hash a random token with fast SHA-256?
The token has high entropy and cannot be feasibly guessed. Passwords are low entropy and need slow hashing. These are different inputs and threat models.
How long should a token live?
Choose the shortest period users can reasonably complete, often 15–30 minutes, and measure delivery delays. High-risk applications may choose less.
Should reset invalidate every session?
For suspected compromise, yes. Some products offer a clear choice, but the default should match the application’s risk and make existing session consequences explicit.
Can support staff view or resend the same token?
No. Support should trigger a new neutral workflow. Raw tokens should not be retrievable.
Related reading
- Continue through the Authentication learning path and browse topic clusters.
- Store the new credential with Password Hashing Explained.
- Record safe recovery events with Security Event Logging Explained.
- Protect recovery services with API Abuse Prevention Explained.
- Review delegated access in OAuth Authorization Code Flow Explained.