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
- 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.
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
- 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.
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
- Set SENTRY_AUTH_TOKEN (or SENTRY_SESSION_TOKEN) to a `sntryu
- Notifications are blocked. Enable them in your browser setti
- REDIS_DOWN
- Expected an array of Sentry issues. A Sentry error body is a
- Sentry returned no resolved issues for ${org}/${project}. A
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)