hcengineering/platform · error · PlatformError

Forbidden

Forbidden

Error message

platform.status.Forbidden

What it means

Thrown by restorePassword when the decoded restore token has no restoreEmail in its extra payload. Without the email bound into the token, the service refuses to restore the password, since it cannot validate which identity is being restored.

Source

Thrown at server/account/src/operations.ts:1646

  ctx: MeasureContext,
  db: AccountDB,
  branding: Branding | null,
  token: string,
  params: { password: string }
): Promise<LoginInfo> {
  const { password } = params

  if (password == null || password === '') {
    throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
  }

  const { account, extra } = decodeTokenVerbose(ctx, token)
  ctx.info('Restoring password', { account, extra })

  const email = extra?.restoreEmail
  if (email === undefined) {
    ctx.error('Email not provided for restoration', { account, extra })
    throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
  }

  const emailSocialId = await getEmailSocialId(db, email)

  if (emailSocialId == null) {
    ctx.error('Email social id not found', { email })
    throw new PlatformError(
      new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
    )
  }

  await setPassword(ctx, db, branding, account, password)

  if (emailSocialId.verifiedOn == null) {
    await db.socialId.update({ key: emailSocialId.key }, { verifiedOn: Date.now() })
  }

  return await login(ctx, db, branding, token, { email, password })

View on GitHub (pinned to 63e28dc964)

Solutions

  1. Request a fresh restore token via the standard restore request flow so restoreEmail is embedded.
  2. Use decodeTokenVerbose locally to check extra.restoreEmail before calling the endpoint.
  3. Upgrade/align service versions so token issuance includes restoreEmail.
  4. Verify you are passing the restore token, not another token type.

Example fix

// before: reuse old token
const ok = await accountClient.restorePassword(ctx, oldToken, { password })
// after: request a new restore token containing the email
const info = await accountClient.requestRestore(ctx, email)
await accountClient.restorePassword(ctx, info.token, { password })
Defensive patterns

Strategy: validation

Validate before calling

const { extra } = decodeTokenVerbose(ctx, token)
if (extra?.restoreEmail === undefined) {
  throw new Error('Token is not a restore token (missing restoreEmail)')
}

Type guard

function isRestoreToken(extra: Record<string, any> | undefined): extra is { restoreEmail: string } & Record<string, any> {
  return typeof extra?.restoreEmail === 'string' && extra.restoreEmail.length > 0
}

Try / catch

try {
  await accountClient.restorePassword(ctx, token, { password })
} catch (err) {
  if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) {
    await accountClient.requestRestore(ctx, email) // re-issue fresh restore token
  } else throw err
}

Prevention

When it happens

Trigger: Using a restore token that was issued without restoreEmail in extra, or a token of a different kind (not a restore token) passed to restorePassword.

Common situations: Tokens generated by older service versions before restoreEmail was added to extra; reusing a changePassword or workspace token for restore; hand-crafted or corrupted tokens.

Understand the failure class

Related errors


AI-assisted analysis of hcengineering/platform@63e28dc964 (2026-08-29). Data as JSON: /api/errors/01c794533d2d2865. Report an issue: GitHub.