forge lint cannot see your request helper: the Jira scopes it never checks
Mihai Perdum
Author
14 min readAugust 17, 2026
Key takeaways
forge lint only recognises a requestJira call when the endpoint is written inline as a literal, template literal or route`` tag. A path arriving as a variable is dropped silently.
One request helper is enough to hide every Atlassian REST call in a codebase. I linted an app needing write:jira-work and read:jira-user with neither declared, and got "No issues found."
On @forge/cli 13.4.0 forge deploy runs the linter and refuses to deploy on errors — so a clean lint now reads like a passed gate rather than a hint.
Measured: a call with an undeclared scope returns HTTP 401 with x-failure-category: FAILURE_CLIENT_SCOPE_CHECK and the body {"code":401,"message":"Unauthorized; scope does not match"}. Not 403.
forge lint --fix can add a scope you do not need, because it defaults an unrecognised method to GET. Adding any scope forces a major version and admin re-consent.
A developer posted on the Atlassian developer community that every Assets API call from their Forge app was returning HTTP 401 {"code":401,"message":"Unauthorized"}, even though they had declared the CMDB scopes in manifest.yml and run forge install --upgrade. Worse, adding those scopes had broken endpoints that were returning 200 before. Someone from Atlassian asked for more detail, the poster explained why their case needs app context rather than user context, and the thread stops there. No accepted answer, no resolution.
That thread is one of a large family. Forge scope problems are hard to debug because the failure arrives far from its cause: you edit a manifest, you deploy, and some call somewhere in your app starts returning 401 — and 401 is also what you get for a dozen unrelated reasons. Atlassian's own advice for getting the scope list right is to let the tooling do it. The scopes documentation says "You can use the forge lint command to assist you with adding missing scopes in your app", and "Rerun forge lint with the --fix argument to automatically add missing scopes to the manifest.yml file."
I have been leaning on that. So this week I sat down to find out exactly what it checks, on the versions that shipped this morning, and what happens at runtime when it gets it wrong. The short version: the linter reads your source with a parser that only recognises an endpoint written inline. If the path arrives through a variable — which is what happens the moment you write a request helper — the call is dropped and no missing scope is ever reported. And since forge deploy started running the linter as a gate, a clean lint result now feels like a passing check rather than a hint.
Everything below was measured on this machine today, 17 August 2026, against @forge/cli 13.4.0 and @forge/lint 6.2.0, both published earlier the same day, with a real app deployed to a real Jira site.
What the tooling actually promises
Two Atlassian pages describe forge lint and they do not describe the same command.
The CLI reference for lint, last updated 6 July 2026, says the command will "check the source files for common errors". Read that page start to finish and you will not find the words permission or scope anywhere on it.
The scopes tutorial is where the permission behaviour is documented, and its wording is careful. The verb is "assist". It shows an example of the linter reporting Confluence endpoint: GET /api/content requires 'read:confluence-content.summary' scope, and it tells you to "continue investigating outstanding errors" afterwards. Nowhere does it claim the check is exhaustive, and nowhere does it list the call shapes the parser cannot read. That page was last updated on 30 October 2024.
So Atlassian never promised completeness. But the tooling changed underneath that wording. Atlassian's changelog puts the new approval workflow — where forge deploy runs checks before it will deploy anything — at @forge/cli 13.3.0. On 13.4.0, which is what I ran, it refuses outright:
text
1$ forge deploy -e development
2Running forge lint...
3Error: The deploy failed due to errors in the app code. Fix the errors before
4rerunning forge deploy, or run forge deploy --no-verify to skip the linter.
56/private/tmp/lz-lint/scopeprobe/src/index.js
755:30 error Jira endpoint: POST /rest/api/3/issue requires "write:jira-work" scope permission-scope-required
That is a meaningful change in how the output reads. A hint you run occasionally is one thing. A check that stands between you and a deploy is something you start to trust. And what it says when it finds nothing is No issues found.
The experiment: five ways to write the same call
Declaring scopes is the most ordinary thing in a Forge manifest — it is four lines you write once when you wire an app up to the Jira REST API and rarely look at again. That is precisely why a check on them needs to be trustworthy or obviously untrustworthy, and not quietly in between.
I built a Forge app whose manifest declares exactly one scope:
yaml
1permissions:2scopes:3- read:jira-work
Then a source file containing five functions. Every one performs an operation that requires write:jira-work. The only thing that differs between them is the shape of the call expression.
js
1importapi,{ route }from'@forge/api';23// (A) literal path, inline options, literal method4exportasyncfunctionshapeA(body){5return api.asApp().requestJira('/rest/api/3/issue',{6method:'POST',7headers:{'Content-Type':'application/json'},8body:JSON.stringify(body)9});10}1112// (B) path held in a variable13exportasyncfunctionshapeB(body){14const path ='/rest/api/3/issue';15return api.asApp().requestJira(path,{16method:'POST',17headers:{'Content-Type':'application/json'},18body:JSON.stringify(body)19});20}2122// (C) options object held in a variable23exportasyncfunctionshapeC(body){24const opts ={25method:'POST',26headers:{'Content-Type':'application/json'},27body:JSON.stringify(body)28};29return api.asApp().requestJira('/rest/api/3/issue', opts);30}3132// (D) method held in a variable33exportasyncfunctionshapeD(body){34constVERB='POST';35return api.asApp().requestJira('/rest/api/3/issue',{36method:VERB,37headers:{'Content-Type':'application/json'},38body:JSON.stringify(body)39});40}4142// (E) route`` tagged template with interpolation43exportasyncfunctionshapeE(issueKey){44return api.asApp().requestJira(route`/rest/api/3/issue/${issueKey}`,{45method:'PUT',46headers:{'Content-Type':'application/json'},47body:JSON.stringify({fields:{summary:'x'}})48});49}
Line 9 is shape A. Line 48 is shape E. Shapes B, C and D are on lines 19, 33 and 39, and the linter says nothing about any of them.
shape
endpoint written as
options written as
reported?
A
string literal
inline object
yes — POST, correct scope
B
variable
inline object
no
C
string literal
variable
no
D
string literal
inline object, method in a variable
no
E
route`…` tagged template
inline object
yes — PUT, correct scope
6 rows × 4 columnsHeader row enabled
Two of five identical operations get checked. This is a positive-control experiment, not a broken harness: the same endpoint, the same required scope and the same manifest produce a correct error in shapes A and E, so the linter can see that endpoint and knows that scope. It simply cannot parse the other three.
(Shapes A to D are written with plain string paths because this is a static-analysis test — forge lint never runs the code. As you will see below, @forge/api 8.x rejects a string path at runtime, so do not copy these into a live app. The shape that is both invisible and deployable comes next.)
Why — it is a six-line guard in the shipped source
The rule lives in out/lint/linters/permission-linter/visitors/product-node-visitor.js. The whole file is 97 lines. The relevant guard:
Three accepted node types for the endpoint: a Literal, a TemplateLiteral, a TaggedTemplateExpression. A variable reference parses as an Identifier, which is none of those, so the function returns undefined and the call never becomes an API call the permission linter can weigh. Same for the options argument: anything that is not an inline ObjectExpression drops the entire call, even when the path beside it is a perfectly readable literal.
Shape D is dropped for a different and more interesting reason, thirty-odd lines further down:
If method: is not a string literal, the linter does not skip the call — it assumes GET. Shape D is a POST, so the linter looked up GET /rest/api/3/issue, which is not a real endpoint, found nothing, and stayed quiet. Hold onto that fallback, because when the fabricated verb does match a real endpoint it does something worse than nothing.
The obvious next question is whether the server-side half of the linter compensates. forge lint runs a client-side pass over your source and a server-side pass through a GraphQL endpoint, and you cannot read the server's rules from the published package. But you can read what it is given. ServerSideLinter.bootstrap() in 6.2.0:
Exactly one file is added to the archive, and it is manifest.yml. No source file is ever uploaded. Whatever the server checks, it is not checking your requestJira calls, because it has never seen them. A call shape the client-side visitor cannot parse is invisible to forge lint end to end, in every mode.
One more thing worth knowing before you check your own version: I pulled both tarballs straight from the registry and diffed them. product-node-visitor.js is byte-identical between @forge/lint 6.1.0 and 6.2.0 — same MD5, 230ea14d37be2a39f12a1e6ae689b725. This morning's release changed the server-side linter and left the endpoint parser alone.
Atlassian's own changelog for the same day confirms what that diff is: "Forge CLI 13.3.0 introduced a server-side manifest check as part of the new approval workflow. However, environment variables in the manifest.yml file were not being interpolated before being sent to the validation endpoint." That is exactly the change — 6.1.0 called readBinaryFile and shipped the raw bytes, 6.2.0 runs the manifest through ManifestParserBuilder().withInterpolators() first. Useful corroboration, and a reminder that this surface is being actively worked on: check the parser against whatever version you have rather than trusting this article's MD5 in six months.
The version that actually bites you
None of the shapes above is code anyone writes deliberately. This is:
js
1importapifrom'@forge/api';23exportasyncfunctionjiraFetch(path, options ={}){4const res =await api.asApp().requestJira(path,{5...options,6headers:{'Content-Type':'application/json',...(options.headers||{})}7});8if(!res.ok)thrownewError(`${res.status} on ${path}`);9return res.status===204?null: res.json();10}
The request helper that almost every Forge codebase grows by its third module — one place for the content type, one place for error handling, one place to add a retry when the rate limiter starts biting you. Inside it, path is a parameter, so the endpoint node is an Identifier and the call is dropped. And at the call sites, the callee is jiraFetch, which does not match the /request(Jira|Confluence|Bitbucket)/ the visitor looks for, so those are not examined either. The calls fall between the two.
Before going further I had to correct my own assumption, and it took a deploy to do it. I first wrote those call sites with plain string paths, and every single one failed at runtime:
text
1Error: You must create your route using the 'route' export from '@forge/api'.
2See https://go.atlassian.com/forge-fetch-route for more information.
On @forge/api 8.0.4 a bare string path is rejected outright. That guard is real and it kills the crudest version of this bug: requestJira('/rest/api/3/issue') now fails loudly in development, long before a scope could matter.
It does not close the hole, because the guard checks the type of the thing you pass, not where you wrote it. A route object held in a variable is a legitimate Route at runtime and an Identifier to the parser. So the helper that actually ships takes route objects, and it is both perfectly valid and completely invisible:
Four calls needing write:jira-work and read:jira-user, against a manifest declaring neither:
text
1$ forge lint -e staging
2The linter checks the app code for known errors.
34No issues found.
One helper function, and the scope linter is blind across the entire codebase. Not degraded — blind. There is no partial credit and no warning that it gave up.
Note
Use an environment you have never deployed to (-e staging above) when reproducing this. Against your default environment the server-side pass compares your manifest to the deployed version, and a scope difference raises a MAJOR_VERSION_RULE approval line that clutters the output. It is not a scope error — but it is noise you do not want while reading for one.
Warning
The blind spot only costs you a scope that is needed exclusively by calls it cannot read. If anywhere else in your app a literal-shaped call needs the same scope, --fix picks it up there and your manifest ends up correct by accident. That is why this can sit in a codebase for a long time and surface on the day someone adds the first endpoint that only the helper touches.
What the runtime does about it
The linter passing means the app deploys. So what happens when it runs?
This is the part I could not find documented anywhere. The permissions manifest reference explains how to declare scopes and says nothing about the failure mode when you have not. Community threads report both 401 and 403 for scope problems and the two get used interchangeably. So I measured it, twice, changing exactly one thing.
The probe is a web trigger that runs four calls through a helper and records the status, the x-failure-category response header and the body. Run one, with read:jira-work as the only declared scope. Run two, identical code, with write:jira-work and read:jira-user added and forge install --upgrade applied.
call
scopes missing
scopes declared
CONTROL asApp GET /rest/api/3/search/jql (declared in both)
200
200
asApp POST /rest/api/3/issue
401, FAILURE_CLIENT_SCOPE_CHECK
201, created WFH-1477
asUser POST /rest/api/3/issue
throws PROXY_ERR … 401 AUTH_TYPE_UNAVAILABLE
throws PROXY_ERR … 401 AUTH_TYPE_UNAVAILABLE
asApp GET /rest/api/3/user/search
401, FAILURE_CLIENT_SCOPE_CHECK
200
5 rows × 3 columnsHeader row enabled
The control is the important row. read:jira-work is declared in both runs and returns 200 in both, which proves the app can reach Jira at all and that the 401s below it are not an authentication or install problem. Without that row the negatives would mean nothing.
So: a missing manifest scope is a 401, not a 403, and it carries a precise fingerprint:
json
1{"code":401,"message":"Unauthorized; scope does not match"}
with the response header x-failure-category: FAILURE_CLIENT_SCOPE_CHECK.
That trailing ; scope does not match is the diagnostic. Note that the developer in the thread I opened with reported a bare {"code":401,"message":"Unauthorized"} — no suffix. On this evidence those are different failures, and the missing-scope one names itself. If you are staring at a Forge 401, read the body and the x-failure-category header before you touch the manifest: this is the same class of trap as Forge egress, where the backend ignores your declared URL path and only the client enforces it — the manifest and the runtime do not always agree about what the manifest means.
One row in that table is not evidence of anything about scopes, and I nearly published it as if it were. The asUser call fails identically in both runs — before and after the scope was added — because a web trigger has no user context to act as, so asUser() cannot resolve an auth type whatever the manifest says. AUTH_TYPE_UNAVAILABLE is telling me about the invocation context, not the permission. It stays in the table because the fact that it did not change is what rules it out.
The wrong scope, written for you
Now back to that 'GET' fallback, with an endpoint where the fabricated verb resolves to something real.
yaml
1permissions:2scopes:3- write:jira-work
js
1importapifrom'@forge/api';23constVERB='POST';45exportasyncfunctionhandler(){6// A POST. The manifest declares write:jira-work, which is correct for it.7return api.asApp().requestJira('/rest/api/3/dashboard',{8method:VERB,9body:JSON.stringify({name:'x'})10});11}
This manifest is correct. POST /rest/api/3/dashboard requires write:jira-work, and write:jira-work is declared. The linter disagrees:
text
17:33 error Jira endpoint: GET /rest/api/3/dashboard requires "read:jira-work" scope permission-scope-required
23X 1 issue (1 error, 0 warnings, 0 approvals)
4 Run forge lint --fix to automatically fix 1 error and 0 warnings.
Wrong verb, wrong scope, on a manifest that needed no change — and because forge deploy runs the linter, this now blocks the deploy of a correct app until you deal with it. Do as the output says:
It wrote a scope the app does not use, inferred from a verb the app never sends. And that is not cosmetic. From the app versions documentation, last updated 3 August 2026, "Modifying scope permissions. This includes: Adding a scope" is a major version upgrade, and major versions "require users and admins to re-consent or review the changes" — unlike minor versions, which Forge installs to all sites automatically.
I watched that happen. Adding scopes to my probe app turned the next deploy into this:
text
1⚠ 1 issue (0 errors, 0 warnings, 1 approval)
2The deploy failed due to 1 approval requested.
3Run forge deploy --approve MAJOR_VERSION_RULE to acknowledge and proceed.
and once approved, the version went from 2.2.0 straight to 3.0.0. So an unnecessary scope written by --fix costs every site admin who has your app installed a consent prompt, and parks your app on an un-upgraded version until each of them clicks it.
What to do instead
The rule that falls out of the parser is simple: the linter can only check an endpoint you write inline at the point of the call. So keep it there, and share everything else.
The instinct to wrap requestJira is right — you do want one place for headers, error handling and retries. Just wrap the response rather than the request:
js
1importapi,{ route }from'@forge/api';23// Shared behaviour, no endpoint passing through it.4exportasyncfunctionunwrap(res){5if(!res.ok){6const detail =await res.text();7thrownewError(`${res.status}${res.statusText}: ${detail.slice(0,200)}`);8}9return res.status===204?null: res.json();10}1112// The endpoint stays inline at the call site, where the linter can read it.13exportasyncfunctioncreateIssue(fields){14returnunwrap(15await api.asApp().requestJira(route`/rest/api/3/issue`,{16method:'POST',17headers:{'Content-Type':'application/json'},18body:JSON.stringify({ fields })19})20);21}
Every call site now presents a TaggedTemplateExpression endpoint and an inline ObjectExpression of options with a literal method, which is exactly the shape the visitor reads. You keep one implementation of your error handling. You give up nothing except the indirection that was hiding the calls.
I did not want to recommend that on reasoning alone, so I linted it — same app id, manifest declaring read:jira-work only:
Wrapping the call in unwrap(...) does not hide it — the visitor walks every node, so an endpoint written inline is found wherever the expression sits. The linter sees this shape and reports the missing scope correctly.
Three things worth doing alongside that:
1
Grep for the shape, not for the bug
grep -rn "requestJira(\|requestConfluence(\|requestBitbucket(" src/ and check that the character after every opening bracket is a backtick or a quote. Anything else is a call forge lint is not checking. This takes a minute and it is the whole audit.
2
Treat --fix output as a diff to review, never as a result
read every scope it adds and confirm the verb it inferred is the verb you send. A scope you did not need is a major version and a consent prompt for every admin; a scope it never saw is a 401 in production.
3
Probe the real thing
deploy a web trigger that exercises your app's actual calls against a test site and asserts on the status codes. Mine is 78 lines and it found the answer the documentation does not contain.
That third one is the habit that matters, and it is worth being blunt about why. Every gate in this story is static: the linter reads your source, the deploy check reads your manifest, the install screen reads your scope list. None of them makes a request. The only thing that knows whether write:jira-work is missing is Jira, at the moment you ask it to do something — which is the same argument for running end-to-end UI tests against a deployed Forge app instead of trusting a local mock. If your app has a scope surface worth protecting, something in CI should be calling the real thing.
We build to this rule in LeanZero Management, our Jira planning app — endpoints inline at the call site, shared behaviour on the response — precisely because the alternative fails silently and only in front of a customer.
The limitations I found
The full chain, on the app I actually deployed: forge lint said No issues found., forge deploy accepted it, forge install reported it would install with read:jira-work, and the app 401'd on its first write. Every gate passed and the app was broken. That is the result I am confident in, because I ran each step and read each output.
Three things I did not establish, and you should not assume from what is above.
I did not test the Confluence or Bitbucket equivalents. The visitor matches all three through one regex and applies the same guard to each, so I expect requestConfluence and requestBitbucket to behave identically — but expecting is not measuring, and I did not run them.
I could not test asUser() under a missing scope in isolation. The web trigger I used has no user context, which is why that row of the table is inert in both runs. Isolating it needs a resolver invoked from a UI module, and it is the next thing I would run.
And I did not test whether the server-side pre-deployment check catches anything my source-level reading missed. I proved it is only ever handed manifest.yml — that part is solid, it is right there in bootstrap() — but a check that only sees your manifest could still, in principle, reject a scope set for reasons of its own. It cannot see your calls, which is the claim I need; it is not a claim that it does nothing.
The generalisation I would take away is narrower than "the linter is broken", because it is not — it does what it says, on the shapes it can read, and Atlassian only ever claimed it would "assist". It is this: a static check that fails open looks exactly like a static check that passed. No issues found. and there is nothing here I could read are printed with the same words. Now that the same check gates forge deploy, that is worth knowing before you rely on it.
---
Measured 17 August 2026 on @forge/cli 13.4.0, @forge/lint 6.2.0, @forge/api 8.0.4, Node v24.15.0, against a Forge app deployed to a Jira Cloud test site. The test issue created by the positive control was deleted afterwards.
Locking in Forge KVS: FAIL_IF_EXISTS works, TTL leases do not