{"record":{"id":"4fdef2c5545036b6","repo":"koala73/worldmonitor","slug":"sentry-returned-403-for-the-issues-endpoint-a-release-scoped","errorCode":null,"errorMessage":"Sentry returned 403 for the issues endpoint. A release-scoped `sntrys_` upload token cannot read issues, no matter which project it was minted for. Export a `sntryu_` user token carrying `event:read` and `project:read` as SENTRY_AUTH_TOKEN (or SENTRY_SESSION_TOKEN) and rerun.","messagePattern":"Sentry returned 403 for the issues endpoint\\. A release-scoped `sntrys_` upload token cannot read issues, no matter which project it was minted for\\. Export a `sntryu_` user token carrying `event:read` and `project:read` as SENTRY_AUTH_TOKEN \\(or SENTRY_SESSION_TOKEN\\) and rerun\\.","errorType":"http","errorClass":"Error","httpStatus":403,"severity":"error","filePath":"scripts/audit-sentry-resolve-pins.mjs","lineNumber":229,"sourceCode":"// sent the bearer token, so it is pinned to the host we chose to trust.\nfunction sameOriginCursor(next) {\n  if (new URL(next).origin !== new URL(SENTRY_HOST).origin) {\n    throw new Error(`Sentry pagination cursor left ${SENTRY_HOST}: ${new URL(next).origin}`);\n  }\n  return next;\n}\n\nexport async function fetchResolvedIssues(token, org, project, fetchImpl = globalThis.fetch) {\n  const issues = [];\n  let url = issuesUrl(org, project);\n\n  for (let page = 0; page < MAX_PAGES; page += 1) {\n    const response = await fetchImpl(url, {\n      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },\n      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n    });\n    if (response.status === 403) {\n      throw new Error(\n        'Sentry returned 403 for the issues endpoint. A release-scoped `sntrys_` upload '\n          + 'token cannot read issues, no matter which project it was minted for. Export a '\n          + '`sntryu_` user token carrying `event:read` and `project:read` as SENTRY_AUTH_TOKEN '\n          + '(or SENTRY_SESSION_TOKEN) and rerun.',\n      );\n    }\n    if (!response.ok) {\n      throw new Error(`Sentry issues request failed: HTTP ${response.status} ${response.statusText}`);\n    }\n    const batch = await response.json();\n    if (!Array.isArray(batch)) throw new Error('Sentry issues response was not an array');\n    issues.push(...batch);\n\n    const { next } = parseLinkHeader(response.headers.get('link'));\n    if (!next) {\n      assertLiveBoardIsNotEmpty(issues, org, project);\n      return issues;\n    }","sourceCodeStart":211,"sourceCodeEnd":247,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/scripts/audit-sentry-resolve-pins.mjs#L211-L247","documentation":"scripts/audit-sentry-resolve-pins.mjs calls Sentry's `/api/0/projects/{org}/{project}/issues/` endpoint with a bearer token, and Sentry answered HTTP 403 (Forbidden). The script raises this specific message because the dominant cause is using a release-scoped `sntrys_` upload token, which by design can only upload artifacts and can never read the issues API, regardless of which project it was minted for.","triggerScenarios":"fetchResolvedIssues() gets `response.status === 403` on any page of the paginated GET to `${SENTRY_HOST}/api/0/projects/${org}/${project}/issues/?query=is:resolved...` — i.e. the Authorization bearer token was rejected for read access to issues.","commonSituations":"CI/CD environments where SENTRY_AUTH_TOKEN holds a `sntrys_` release/upload token (as used by sentry-cli artifact uploads) instead of a user token; a token minted without `event:read`/`project:read` scopes; a revoked or membership-stripped token; a token belonging to a different org than SENTRY_ORG.","solutions":["Create a `sntryu_` user auth token in Sentry (Settings > Auth Tokens) with `event:read` and `project:read` scopes.","Export it as SENTRY_AUTH_TOKEN (or SENTRY_SESSION_TOKEN) in the shell/CI where the audit runs, replacing the `sntrys_` token.","Verify the token's organization matches SENTRY_ORG/SENTRY_PROJECT and that the token owner is a member of that project.","Alternatively, run offline with a saved payload: `node scripts/audit-sentry-resolve-pins.mjs --input <saved-issues.json>`.","If the token is correct but 403 persists, re-mint it — it may have been revoked or scoped down."],"exampleFix":"// before (CI config)\nSENTRY_AUTH_TOKEN=sntrys_eyJpYXQ...  # release upload token\n// after\nSENTRY_AUTH_TOKEN=sntryu_9f2a...  # user token with event:read + project:read","handlingStrategy":"validation","validationCode":"// before invoking the live audit\nconst token = process.env.SENTRY_SESSION_TOKEN || process.env.SENTRY_AUTH_TOKEN;\nif (!token) throw new Error('SENTRY_AUTH_TOKEN is not set');\nif (token.startsWith('sntrys_')) {\n  throw new Error('sntrys_ tokens are upload-only and cannot read issues; use a sntryu_ user token');\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Keep upload tokens (`sntrys_`) and user tokens (`sntryu_`) in separate env var names so they cannot be confused.","Always mint audit/read tokens with `event:read` and `project:read` scopes.","Smoke-test the token with a cheap GET (e.g. /api/0/organizations/) before running the audit.","Prefix CI secret names with their purpose, e.g. SENTRY_READ_AUTH_TOKEN vs SENTRY_UPLOAD_AUTH_TOKEN."],"tags":["sentry","http-403","auth-token","permissions","ci"],"backgroundTag":"permission-denied","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}