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

  1. Request a fresh reset email and use the newest link promptly.
  2. Increase `auth.forgotPassword.expiration` if delivery latency is high.
  3. Ensure the token is passed verbatim (no URL-decoding issues) from the email link to the POST body.
  4. 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

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


AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12). Data as JSON: /api/errors/65751c101c701a6e. Report an issue: GitHub.