hcengineering/platform · error · ApiError

Forbidden: read-only token

Error message

Forbidden: read-only token

What it means

After decoding the auth token, the pod-export server rejects requests whose token carries an extra.readonly flag (any value). This HTTP 403 error exists because export creates data in the target workspace, which a read-only token is not permitted to do.

Source

Thrown at services/export/pod-export/src/server.ts:509

          measureCtx.warn(`Invalid targetWorkspace parameter: ${String(targetWorkspace)}`)
          throw new ApiError(400, 'Missing or invalid required parameter: targetWorkspace')
        }
        if (_class == null || typeof _class !== 'string') {
          measureCtx.warn(`Invalid _class parameter: ${String(_class)}`)
          throw new ApiError(400, 'Missing or invalid required parameter: _class')
        }
        if (conflictStrategy !== undefined && conflictStrategy !== 'skip' && conflictStrategy !== 'duplicate') {
          measureCtx.warn(`Invalid conflictStrategy: ${String(conflictStrategy)}`)
          throw new ApiError(400, 'Invalid conflictStrategy. Must be "skip" or "duplicate"')
        }
        if (includeAttachments !== undefined && typeof includeAttachments !== 'boolean') {
          measureCtx.warn(`Invalid includeAttachments: ${String(includeAttachments)}`)
          throw new ApiError(400, 'Invalid includeAttachments. Must be boolean')
        }

        decodedToken = decodeToken(token)
        if (decodedToken.extra?.readonly !== undefined) {
          throw new ApiError(403, 'Forbidden: read-only token')
        }

        // Get target workspace info
        const accountClient = getClient(envConfig.AccountsUrl, token)
        const targetWsLoginInfo = await accountClient.getLoginWithWorkspaceInfo()

        const targetWsInfo = targetWsLoginInfo.workspaces[targetWorkspace]
        if (targetWsInfo === undefined) {
          measureCtx.warn(`Target workspace not found or not accessible: ${targetWorkspace}`)
          throw new ApiError(404, 'Target workspace not found or not accessible')
        }

        // Verify user has write access to target workspace
        const isAdmin: boolean = decodedToken.extra?.admin === 'true'
        if (!isAdmin && targetWsInfo.role !== AccountRole.Owner) {
          measureCtx.warn(
            `User does not have write access to target workspace: ${targetWorkspace}, role: ${targetWsInfo.role}`
          )

View on GitHub (pinned to 63e28dc964)

Solutions

  1. Obtain a non-read-only token (mint a new one without the readonly extra flag) and retry.
  2. Use a full-permission account/token for cross-workspace export operations.
  3. If the readonly flag is wrong, regenerate the token with correct scope configuration.

Example fix

// before
curl -H 'Authorization: Bearer <readonly-token>' .../export
// after
curl -H 'Authorization: Bearer <token-with-write-scope>' .../export
Defensive patterns

Strategy: try-catch

Validate before calling

const decoded = decodeToken(token)
if (decoded?.extra?.readonly !== undefined) {
  throw new Error('Refusing to call export with a read-only token')
}

Type guard

function isWritableToken(decoded: DecodedToken): boolean {
  return decoded.extra?.readonly === undefined
}

Try / catch

try {
  await exportPod({ token })
} catch (err) {
  if (err instanceof ApiError && err.status === 403 && /read-only/i.test(err.message)) {
    console.error('Swap to a token without the readonly flag and retry')
  } else throw err
}

Prevention

When it happens

Trigger: Authenticating the export call with a token whose decoded `extra.readonly` property is defined (e.g. a read-only service account or restricted key), regardless of the actual flag value ('true' or even 'false').

Common situations: Ops scripts using a deliberately restricted read-only key to run an import/migration into another workspace; reusing a shared read-only token from monitoring/reporting tooling; a stale token minted with readonly scope.

Understand the failure class

Related errors


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