Forge egress: backend ignores your URL path, client enforces it
Mihai Perdum
Author
14 min readAugust 8, 2026
Key takeaways
A path on an external.fetch.backend entry does nothing. Backend egress matches on protocol plus hostname only, so declaring one path grants the whole domain.
The same path on an external.fetch.client entry DOES narrow the grant, because the client list becomes a CSP source expression and CSP matches paths.
A blocked backend fetch does not throw. It returns a real Response with status 403, so code that ignores res.ok treats a denial as data.
Plain strings in the egress lists are deprecated in Forge CLI 13.3.0. The new form is a list of address objects, and forge lint --fix migrates it for you.
A wildcard never covers its own parent domain. Declaring *.example.com does not grant example.com, and I have the 403 to prove it.
In May 2026 a developer called VivekBurman asked a narrow question on the Atlassian developer community. He was onboarding on-premise customers whose domains he could not know in advance, and he wanted out of the re-upload treadmill, so he asked whether Forge would accept a bare * as the value under external → fetch → backend.
Atlassian's Benny answered the next day: "yes that is valid but some customers may have issue with that."
Both of them are right, and the thread closed there. But underneath that question sits a much bigger one nobody asked, which is what an egress entry actually matches when it is not a bare star. I went looking, because I had assumed for years that the path I type into a Forge manifest narrows something. It does not — and worse, whether it narrows anything depends on which of two adjacent lists you put it in.
Here is what the permissions reference says, which I re-read on 8 August 2026. The paragraph opens by telling you it is about "listing the domains directly in the fetch.backend or fetch.client section", and then says:
You don't need to specify individual URL paths, such as example-dev.com/path. Adding one domain allows access to any URL on that domain.
So it names both lists in the same breath and then gives them one rule. That rule is true for exactly one of them.
The setup
I built a throwaway Forge app whose only job is to fetch things and report what happened, and installed it on a sanctioned test site. The whole app is a web trigger, so I can hit it with curl and get JSON back instead of squinting at a UI.
The manifest declares two backend entries and one client entry. Note that the backend list mixes the two forms I want to test — a URL carrying a path, and a leftmost wildcard:
The probe then asks for five URLs. Four of them are chosen to sit just outside or just inside a declaration, and the fifth exists purely so the test can fail:
js
1constTARGETS=[2{id:'A',url:'https://api.github.com/zen',why:'declared verbatim (path form)'},3{id:'B',url:'https://api.github.com/octocat',why:'SAME domain, DIFFERENT path'},4{id:'C',url:'https://en.wikipedia.org/wiki/Main_Page',why:'subdomain of *.wikipedia.org'},5{id:'D',url:'https://wikipedia.org/',why:'PARENT of *.wikipedia.org'},6{id:'E',url:'https://dummyjson.com/test',why:'undeclared domain (CONTROL)'}7];
E is the important one and I want to be explicit about why. If I only ran A, B and C and they all returned 200, I would have learned nothing at all — an egress sandbox that is silently switched off also returns 200 for everything. E is an undeclared domain that must be refused. Until E comes back blocked, every other green result in this article is worthless.
The handler itself does two things per target: it asks the in-process permissions API what it thinks will happen, then actually tries it.
1forge deploy -e development
2forge install--site your-site.atlassian.net --product jira -e development
3forge webtrigger -e development
4curl-s"https://<app-id>.hello.atlassian-dev.net/x1/<token>"
What came back
Four runs, across four deploys, one verdict pattern:
id
request
declared as
result
canFetchFrom backend
canFetchFrom client
A
api.github.com/zen
that exact URL
200
true
true
B
api.github.com/octocat
same domain, other path
200
true
false
C
en.wikipedia.org/wiki/Main_Page
subdomain of the wildcard
200
true
false
D
wikipedia.org
parent of the wildcard
403
false
false
E
dummyjson.com/test
nothing at all
403
false
false
6 rows × 6 columnsHeader row enabled
E is blocked. The control holds, so the rest of the table means something.
Row B is the headline. I declared exactly one path on api.github.com and got the entire host. The path is decoration. Row D is the other half: *.wikipedia.org does not cover wikipedia.org, which the docs do say and which I can now show rather than repeat.
And row B's last two columns disagree with each other. The same URL is true for backend and false for client, in the same process, in the same invocation.
The 403 that isn't an exception
Look again at what a denial actually is:
json
1{2"id":"E",3"threw":false,4"status":403,5"ok":false,6"body":"URL not included in the external fetch backend permissions: https://dummyjson.com. Visit go.atlassian.com/forge-egress for more information.",7"ms":128}
threw: false. A blocked egress call is not an exception. It is a perfectly ordinary Response object with a 403 status and an explanatory body, which means this extremely common shape is a bug:
js
1// WRONG — a blocked call sails straight through this2const res =awaitfetch(url);3const data =await res.json();
If url is not in your egress list, res.json() throws a JSON parse error somewhere far away from the actual cause, and your logs blame the remote API for returning garbage. Check res.ok, and if it is false, read the body before you assume the other end misbehaved.
The denial also reports the domain (https://dummyjson.com), not the URL you asked for — another tell that matching never looked at your path.
There is a cleaner way to detect this than string-matching the body, and I want to credit it: in a separate thread, a developer posting as clouless suggested keying off a response header rather than the message. I had not seen that documented anywhere, so I captured the full header set on both refusals to check whether it is real. It is:
1const res =awaitfetch(url);2if(!res.ok&& res.headers.get('forge-proxy-error')==='BLOCKED_EGRESS'){3thrownewError(`Egress not declared for ${url} — add it to permissions.external.fetch`);4}
forge-proxy-upstream-latency: 0 is the part I find genuinely satisfying. It is not an inference from wall-clock timing; the proxy is telling you outright that it spent zero milliseconds upstream, because it never went upstream. That matches what I measured from the outside — refusals returned in 11–50 ms across all four runs, against 160–357 ms for calls that actually reached the network — but the header is the evidence and the timing is just the shadow of it.
Why: the matcher, read rather than guessed
Instead of inferring the rule from five data points, I read the code that implements it. It ships in your own node_modules — @forge/egress, version 3.0.0, pulled in by @forge/api 8.0.3. The whole decision is two methods:
js
1isValidUrl(url){// used for BACKEND2const parsedUrl =this.parseUrl(url);3returnthis.allowedDomainExact(parsedUrl,this.URLs)4||this.allowedDomainPattern(parsedUrl,this.wildcardDomains);5}67isValidUrlCSP(url){// used for CLIENT8const parsedUrl =this.parseUrl(url);9returnthis.allowedDomainExactAndPath(parsedUrl,this.URLs)10||this.allowedDomainPatternAndPath(parsedUrl,this.wildcardDomains);11}
allowedDomainExact compares protocol and hostname. That is the entire backend rule — the pathname the parser carefully extracted is never consulted. The client rule calls the AndPath variants, which add this:
Those are Content Security Policy source-expression semantics — the trailing-slash prefix rule is straight out of CSP — and that is the reason for the split.
The docs do reach for CSP vocabulary here, saying that in each section "you can add a list of external domains, which end up as a source in an equivalent CSP directive." But note that sentence says each section, and the two sections plainly do not behave the same, so I would not lean on it too hard. The behaviour I can actually stand behind comes from the other two sources: the shipped matcher applies CSP path semantics to the client list and plain hostname equality to the backend list, and the platform agreed with that when I ran it. Backend is enforced by a proxy — the server: envoy and forge-proxy-error headers above are that proxy introducing itself — and a proxy sitting on domain granularity is exactly what row B measured.
The two lists look identical in YAML and are evaluated by two different rules.
The practical consequences of pathMatches are sharper than they look. Running the matcher directly against the client rule:
declared (client)
requested
allowed
https://api.example.com/v1/orders
https://api.example.com/v1/orders
yes
https://api.example.com/v1/orders
https://api.example.com/v1/orders/123
NO
https://api.example.com/v1/
https://api.example.com/v1/orders/123
yes
https://api.example.com
https://api.example.com/anything
yes
5 rows × 3 columnsHeader row enabled
A trailing slash is the difference between a prefix and an exact match. Declare /v1/orders on the client side and a request to /v1/orders/123 is refused. That is a genuinely easy way to ship an app that works in your resolver and dies in the browser.
The wildcard rule is worth pinning down too, because the docs publish a regex that does not tell you this. *.example.com is compiled by escaping the dots and turning * into .*, giving /^.*\.example\.com$/. So it matches a.example.com and also a.b.c.example.com — arbitrarily deep, not one label. It does not match example.com, because the pattern demands a literal leading dot, which is exactly what row D measured. If you need both, you declare both.
The introspection API almost nobody uses
canFetchFrom came from @forge/api, not @forge/bridge, and that distinction has cost people real time. Both packages export something called permissions, and they are completely different APIs. The @forge/bridge one is the async customer-managed egress and remotes API and is frontend-only. The @forge/api one is synchronous manifest introspection: hasPermission, hasScope, canFetchFrom, canLoadResource. No promises, no bridge, and it works fine in a resolver.
I mention this because a developer working through the same problem concluded in public that "as long as that is the case the permission API relies on bridge there will be no resolver compatible code", and fell back to the forge-proxy-error header I showed above. That fallback is correct and I have now verified it works. But it is a post-mortem: you learn the call was refused by making it. The @forge/api half lets you ask first.
I checked the introspection against reality rather than trusting it. Five targets across four runs is twenty predictions, and canFetchFrom('backend', url) matched what the platform actually did on all twenty, refusals included. It never threw, and it never raised the ApiNotReadyError that this API can raise when the app context has not landed yet. So this is a legitimate pre-flight:
js
1import{ fetch, permissions }from'@forge/api';23exportasyncfunctioncallOut(url){4if(!permissions.canFetchFrom('backend', url)){5thrownewError(`Not in the manifest egress list: ${url}`);6}7const res =awaitfetch(url);8if(!res.ok)thrownewError(`${res.status}: ${await res.text()}`);9return res.json();10}
One caveat I want to be honest about: canFetchFrom runs the same matcher I quoted above, inside your process, against the permission list the platform handed you. Twenty for twenty is twenty for twenty — but it is a local re-implementation of the rule and not the enforcement point itself. Treat a false as authoritative and a true as a strong expectation.
While I was in there I dumped what the platform actually hands the app:
The path survives. Atlassian does not strip /zen at deploy time — it stores what you wrote, in both lists, and then discards it at match time for exactly one of them. Which is why this is so easy to get wrong: nothing in the manifest, the deploy output or the app context tells you the string means two different things.
Your egress syntax is now deprecated
If you have not touched a manifest in a few months, run forge lint on Forge CLI 13.3.0 and you will find this waiting:
text
1warning There are deprecated egress permission entries for 'fetch.backend' in the manifest.yml file
2warning There are deprecated egress permission entries for 'fetch.client' in the manifest.yml file
Any plain string in permissions.external.fetch.* — and in fonts, styles, frames, images, media and scripts — is deprecated. The replacement is a list of objects:
forge lint --fix performs that migration correctly, including quoting the wildcard entries, so this is a one-command change. It also adds an empty scopes: [] if you did not have one.
Warning
forge lint --fix reported "No issues found" while forge lint on the very next line reported one outstanding warning and told me to run --fix to clear it. I reproduced that loop three times on CLI 13.3.0. The warning it cannot fix is the backend-path one, which makes sense — stripping a path would change your declaration — but --fix claiming a clean sheet is misleading. Trust forge lint, not forge lint --fix, for whether you are clean.
The linter does flag the path problem, and this is the exact string:
text
1warning Detected a backend egress URL with a path in manifest.yml:
2'https://api.github.com/zen'. Currently, backend egress only validates domains,
3so the path is ignored.
Note what it does not do. My manifest declared the identical URL, path and all, under client as well. No warning was raised for that entry — correctly, because there the path is load-bearing. The guard in the shipped validator is explicitly extPermType === 'external.fetch.backend'. So the CLI already encodes the asymmetry that the documentation does not mention.
Back to the bare star
VivekBurman's original question deserves a real answer, so I ran it.
A bare * under external.fetch.backend is accepted. It produces a warning and not an error:
text
1warning Global URL usage detected for 'external.fetch.backend' permission in the
2manifest.yml file. We recommend using a more specific URL.
That is a special case, not a regex match. The validator checks inputURL === '*' before anything else; the domain pattern Atlassian publishes would reject a bare star, and so would the includes('.') guard sitting two lines below it.
The star is not accepted everywhere, though, and the failure mode here is subtle enough that I got it wrong first time. I had read that * is rejected as a remote baseUrl. So I wrote a manifest with baseUrl: '*' and linted it — and it passed clean. The claim looked false.
It is not false; it is conditional. The remote's baseUrl is only validated when a fetch permission references that remote by key. A dangling remote nobody points at is never checked. With the reference in place:
1error Invalid 'external.fetch.backend' permission in the manifest.yml file - 'star-remote'.
I ran the same shape with baseUrl: 'https://api.example.com' as a control and it linted clean, so the error is genuinely about the star. Two things to take from this. First, a star is legal in a permission list and illegal in a remote, which matters if you were planning to reach PINNED data residency — that requires an exact string match between your fetch entry and a remote declaration, and a bare star can never have a matching remote by construction. Second, read that error message carefully: it names the permission and the remote's key, and says nothing about baseUrl being the problem. I spent a while looking at the wrong block.
Where this bit us
Tip
This is the bit where I mention one of ours, so treat it accordingly. We ship CogniRunner, a Forge app that puts AI validators and conditions on Jira workflow transitions, and it has to reach model providers — Anthropic, OpenAI, OpenRouter, Bedrock — which means its manifest carries the same set of domains in fetch.client and fetch.backend at once. That is the exact shape this article is about: one list where a path would be ignored, one where it would be enforced, sitting four lines apart. We declare bare domains in both, no paths anywhere, which sidesteps the whole problem. Had we "helpfully" narrowed those entries to /v1/messages, the backend copy would have silently kept granting the whole host while the client copy pinned us to an endpoint every provider eventually moves.
What I did not test
Three things, and I would rather name them than let the piece imply more than it proved.
I did not put a browser in front of the client-side rule. Everything I show about client comes from two places: the shipped matcher, which I read and ran, and canFetchFrom('client', …) returning false for a different path inside a deployed resolver. Both are real and they agree, and the CSP semantics in pathMatches are not ambiguous. But I did not read the Content-Security-Policy header off a live custom UI iframe and confirm the browser refuses the request, because the stored session I had for that site turned out to be stale and a fresh login needs interactive 2FA. So: backend behaviour is measured on the platform, client behaviour is measured one layer in from it.
I did not test customer-managed egress, which is still a Preview feature as of 8 August 2026 and lets admins define the domains instead of you. If you are heading that way, know that its consent modal is scoped per domain and not per egress type — the docs are explicit that "if egress has already been configured for a particular domain and that domain is reused for a different type, the modal is not shown again for the same domain."
And I did not test http versus https on the platform. In the matcher, protocol is compared before hostname, and a declared https entry does not match an http request — so declare the scheme you will actually call, but take that one from the code rather than from a live run.
The short version
Declare domains, not URLs, on backend — anything else is a comment. Declare the narrowest thing you will genuinely call on client, and remember the trailing slash is the difference between a prefix and an exact match. Check res.ok, because a denial is a 403 and not an exception. And run forge lint after forge lint --fix, because --fix will tell you it found nothing while a warning is still sitting there.
The five-target probe took about twenty minutes to build, and the only part that required any thought was target E — the one that had to fail. If you are carrying egress declarations you inherited, point a probe like this at your own manifest before you trust them. Mine did not behave the way I had assumed it did.
A later probe built the same way answered a different manifest question: what Forge actually returns when a declared scope is missing turns out to be 401 with x-failure-category: FAILURE_CLIENT_SCOPE_CHECK, and the piece also shows why forge lint never warned about the call in the first place.
Locking in Forge KVS: FAIL_IF_EXISTS works, TTL leases do not