koala73/worldmonitor · error · Error

Sentry returned 403 for the issues endpoint. A release-scope

Error message

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.

What it means

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.

Solutions

  1. Create a `sntryu_` user auth token in Sentry (Settings > Auth Tokens) with `event:read` and `project:read` scopes.
  2. Export it as SENTRY_AUTH_TOKEN (or SENTRY_SESSION_TOKEN) in the shell/CI where the audit runs, replacing the `sntrys_` token.
  3. Verify the token's organization matches SENTRY_ORG/SENTRY_PROJECT and that the token owner is a member of that project.
  4. Alternatively, run offline with a saved payload: `node scripts/audit-sentry-resolve-pins.mjs --input <saved-issues.json>`.
  5. If the token is correct but 403 persists, re-mint it — it may have been revoked or scoped down.

Example fix

// before (CI config)
SENTRY_AUTH_TOKEN=sntrys_eyJpYXQ...  # release upload token
// after
SENTRY_AUTH_TOKEN=sntryu_9f2a...  # user token with event:read + project:read
Defensive patterns

Strategy: validation

Validate before calling

// before invoking the live audit
const token = process.env.SENTRY_SESSION_TOKEN || process.env.SENTRY_AUTH_TOKEN;
if (!token) throw new Error('SENTRY_AUTH_TOKEN is not set');
if (token.startsWith('sntrys_')) {
  throw new Error('sntrys_ tokens are upload-only and cannot read issues; use a sntryu_ user token');
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/4fdef2c5545036b6. Report an issue: GitHub.

Appendix: source

Thrown at scripts/audit-sentry-resolve-pins.mjs:229

// sent the bearer token, so it is pinned to the host we chose to trust.
function sameOriginCursor(next) {
  if (new URL(next).origin !== new URL(SENTRY_HOST).origin) {
    throw new Error(`Sentry pagination cursor left ${SENTRY_HOST}: ${new URL(next).origin}`);
  }
  return next;
}

export async function fetchResolvedIssues(token, org, project, fetchImpl = globalThis.fetch) {
  const issues = [];
  let url = issuesUrl(org, project);

  for (let page = 0; page < MAX_PAGES; page += 1) {
    const response = await fetchImpl(url, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });
    if (response.status === 403) {
      throw new Error(
        '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.',
      );
    }
    if (!response.ok) {
      throw new Error(`Sentry issues request failed: HTTP ${response.status} ${response.statusText}`);
    }
    const batch = await response.json();
    if (!Array.isArray(batch)) throw new Error('Sentry issues response was not an array');
    issues.push(...batch);

    const { next } = parseLinkHeader(response.headers.get('link'));
    if (!next) {
      assertLiveBoardIsNotEmpty(issues, org, project);
      return issues;
    }

View on GitHub (pinned to 7d06c8633d)