payloadcms/payload · error · APIError
Token is either invalid or has expired.
Error message
Token is either invalid or has expired.
What it means
Thrown in `resetPassword` after `findOne` with `where: { resetPasswordToken: { equals }, resetPasswordExpiration: { greater_than: now } }` returns no user. The token does not match any document or has exceeded its validity window. `APIError` with HTTP 403 (FORBIDDEN).
Source
Thrown at packages/payload/src/auth/operations/resetPassword.ts:95
// /////////////////////////////////////
const where = appendNonTrashedFilter({
enableTrash: Boolean(collectionConfig.trash),
trash: false,
where: {
resetPasswordExpiration: { greater_than: new Date().toISOString() },
resetPasswordToken: { equals: data.token },
},
})
user = await payload.db.findOne<User>({
collection: collectionConfig.slug,
req,
where,
})
if (!user) {
throw new APIError('Token is either invalid or has expired.', httpStatus.FORBIDDEN)
}
// TODO: replace this method
const { hash, salt } = await generatePasswordSaltHash({
collection: collectionConfig,
password: data.password,
req,
})
user.salt = salt
user.hash = hash
user.resetPasswordExpiration = new Date().toISOString()
if (collectionConfig.auth.verify) {
user._verified = Boolean(user._verified)
}
View on GitHub (pinned to 00c58b35c0)
Solutions
- Request a fresh reset email and use the newest link promptly.
- Increase `auth.forgotPassword.expiration` if delivery latency is high.
- Ensure the token is passed verbatim (no URL-decoding issues) from the email link to the POST body.
- If expired tokens are common, review email delivery latency and the expiration default.
Example fix
// before — calling with a possibly-stale token
await payload.resetPassword({ collection, data: { token, password }, req })
// after — handle expiry by re-issuing
try {
await payload.resetPassword({ collection, data: { token, password }, req })
} catch (e) {
if (e.message.includes('invalid or has expired')) {
await payload.forgotPassword({ collection, data: { email }, req })
}
} Defensive patterns
Strategy: try-catch
Validate before calling
// Best-effort: check token freshness against expiration config before posting
const expMs = (collectionConfig.auth.forgotPassword?.expiration ?? 600) * 1000
if (Date.now() - issuedAt > expMs) { return requestNewResetEmail() } Type guard
function isTokenInvalidError(e: unknown): e is APIError {
return e instanceof APIError && e.status === 403
&& /invalid or has expired/.test(e.message)
} Try / catch
try {
await payload.resetPassword({ collection, data: { token, password }, req })
} catch (e) {
if (isTokenInvalidError(e)) {
await payload.forgotPassword({ collection, data: { email }, req })
} else throw e
} Prevention
- Use the most recent reset link; old ones are invalidated after use.
- Tune `auth.forgotPassword.expiration` if email delivery is slow.
- Forward the token verbatim, watching for URL-encoding issues.
When it happens
Trigger: The user clicks an expired reset link (`resetPasswordExpiration` elapsed, default 10 minutes unless configured); the token was already consumed by a previous reset (token cleared after use); the token is malformed/copy-pasted incompletely; the token never existed.
Common situations: Slow email delivery pushes the click past the expiration; user requests multiple resets and clicks an old link; URL encoding mangles the token; the previous successful reset nulled `resetPasswordToken` so the same token is now invalid.
Related errors
- Missing required data.
- error:notAllowedToPerformAction
- Verification token is invalid.
- Cannot refresh token: user not authenticated
- No auth config found for collection: ${collection}
AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12).
Data as JSON: /api/errors/65751c101c701a6e.
Report an issue: GitHub.