Forge app access rule: what your app sees when a data security policy blocks it
Mihai Perdum
Author
14 min readSeptember 29, 2026
Key takeaways
END STATE: a Forge app that subscribes to both data security policy events, logs their real payloads, and can ask Jira at any time whether a project is blocked to it.
A blocked app gets no policy error. Reading a blocked issue returned HTTP 404 with 'Issue does not exist or you do not have permission to see it.', and a JQL search returned HTTP 200 with an empty issues list. The same issue read as a user returned 200.
Two event types exist, both for BLOCKED access: avi:ecosystem.app_policy:blocked:app_access_to_objects.v2 and avi:ecosystem.app_policy:blocked:app_access_to_objects_in_container.v2. No event is documented for access coming back, and none arrived when I removed the block.
Every container event on our site arrived twice with two different event ids, so deduplicate on type, data and time, not on id.
GET /rest/api/3/data-policy/project?ids=<projectId> is exempt from the app access rule, so the blocked app can still call it. It is the only way we found for an app to learn that access came back.
A Forge app access rule is the part of an Atlassian data security policy that blocks Marketplace and custom apps from content in chosen Jira projects or Confluence spaces. When an admin turns it on, your Forge app is not told in the response. I blocked a small probe app from one Jira project on our test site, and the next call it made for an issue it had read a minute earlier came back like this (condensed to one line per call: name, status, body):
text
1issue 404 {"errorMessages":["Issue does not exist or you do not have permission to see it."],"errors":{}}
2search 200 {"issues":[],"isLast":true}
That is the same message Jira gives for an issue that does not exist. The search did not fail at all, it just returned nothing. If your app treats a 404 as "deleted" or an empty search as "nothing to do", a policy change will quietly look like data loss to your users, and your app might even act on it.
This tutorial builds the probe I used, walks through blocking it, and shows what the platform actually sends: the statuses, the two data security policy events with their real payloads, the REST endpoint a blocked app can still call, and what happened when I took the block away. Everything below was run on 28 September 2026 on wolfaenpak, our own test Jira Cloud site, with the Forge CLI 12.21.0 and @forge/api 8.1.0. The admin side of the same feature (which controls need Guard, how overrides work) is covered in our data security policy post, so I won't repeat it here.
Note
Prerequisites
A Jira Cloud site you can break things on, and organization admin rights on its org (you need them to create the policy).
The Forge CLI logged in (forge whoami works). I used 12.21.0.
An organization API key from admin.atlassian.com with policy read, write and delete permissions, for the Admin Control API calls in steps 5 and 9. You can do the same steps in the admin UI instead.
One throwaway Jira project with two issues in it. Mine is project DSP, id 11669, with DSP-1 (id 38309) and DSP-2 (id 38310).
Why a Forge app access rule is invisible to your app
The app access rule is applied on top of user permissions, not instead of them. Atlassian's coverage summary puts it as "applied along with, not instead of, the user's permissions." So a user who can see an issue still sees it. Your app, calling as itself, does not.
Three things about the design matter before you write any code.
First, the block is silent on the data APIs. Atlassian's developer guide says items already blocked to an app on install "will not appear in subsequent search or any other data retrieval results, nor will the app be able to update them." That sentence is about install, but the same silence applied to a block that landed while my probe was installed: there is no special status code for "blocked by policy". I checked for one and got the ordinary 404.
Second, the notification comes as events, and only for blocking. The events reference lists two event types and both are about access being blocked. There is nothing documented for access being restored.
Third, installs do not trigger anything. The events page is explicit: the events "are NOT triggered by changes in app installs or uninstalls. Therefore, if an app is installed after policies have been created, no notifications are sent out that app access (by that app) is blocked." An app installed into a site that already has a policy will never get an event about it. It has to ask.
That third point is why the probe below does two jobs: it listens for events, and it can be called at any time to check the policy state directly.
Build a probe app that subscribes to data security policy events
The probe is one manifest and one file. The manifest declares a trigger per event type, both pointing at one handler that logs the whole event, and a web trigger that reads an issue, runs a search and asks the data policy endpoints what they think.
1
Create the app
run forge create and pick any blank template, then replace manifest.yml and src/index.js with the two files below. Keep the app.id that forge create gave you.
2
Subscribe both triggers
the two trigger entries use the exact event names from Atlassian's data security policy events reference. The events need no extra scope; the page says "This event does not require any additional Forge scopes."
3
Add the probe
the web trigger calls api.asApp().requestJira against five routes and returns the status and body of each.
4
Deploy and install
deploy to development, install into Jira on your test site, and create the web trigger URL.
5
Block the project
create a data security policy with an app access rule set to block, cover the test project, and publish it.
6
Probe again
call the web trigger and compare with the first run.
7
Read the events
pull forge logs and look for the lines the handler wrote.
8
Check the policy from inside the app
read /rest/api/3/data-policy/project for the project.
1importapi,{ route }from'@forge/api';23// Trigger handler: log the whole event so `forge logs` shows the payload.4exportconstonPolicyEvent=async(event, context)=>{5console.log('DSP event',JSON.stringify(event));6returntrue;7};89// Web trigger: read one issue as the app, plus the data-policy endpoints.10exportconstprobe=async(req)=>{11const key = req.queryParameters?.issue?.[0]??'DSP-1';12const out ={};13for(const[name, r]of[14['issue', route`/rest/api/3/issue/${key}?fields=summary`],15['search', route`/rest/api/3/search/jql?jql=${'project = DSP'}&fields=summary`],16['dataPolicy', route`/rest/api/3/data-policy`],17['dataPolicyProjects', route`/rest/api/3/data-policy/project?ids=${'11669'}`],18['project', route`/rest/api/3/project/DSP`],19]){20const res =await api.asApp().requestJira(r);21 out[name]={status: res.status,body:await res.text()};22}23console.log('probe',JSON.stringify(out));24return{statusCode:200,headers:{'Content-Type':['application/json']},body:JSON.stringify(out,null,2)};25};
Change DSP and 11669 to your own project key and id. The only scope is read:jira-work, which is also the scope Jira's API spec lists for both data policy endpoints.
Deploy, install and get the URL:
bash
1forge deploy -e development
2forge install-e development --site<your-site>.atlassian.net --product jira
3forge webtrigger create -f probe -s<your-site>.atlassian.net -p jira -e development
The install should list exactly one scope. Mine printed:
text
1Your app will be installed with the following scopes:
2- read:jira-work
3...
4✔ Install in Jira complete!
Now call the web trigger URL with curl -s "<webtrigger-url>" before any policy exists. This is your baseline, and you should see every call succeed. My first baseline, at 01:13:27 UTC, came from an earlier version of the probe that called the project endpoint without ?ids= and had no project call yet, so it shows four lines instead of five:
I've trimmed the expand and self fields from the issue bodies; the statuses and keys are as returned. I only ran the printed version after the test, with the org-level allow policy described below still in place. It returned {"projectDataPolicies":[{"id":11669,"dataPolicy":{"anyContentBlocked":false}}]} for the project plus project 200, while the site-level line said true. I haven't run it against a site with no policy at all.
Block app access with a data security policy
You can do this in admin.atlassian.com under the data security policy page, and most admins will. I used the Admin Control API so each step leaves a status code behind. It took three calls: create a policy, attach the project, publish.
Warning
Read this before you run the calls below. Alongside the block policy I also created an org-level policy, and once it was published I could not delete it through the API: every route I tried returned 400 or did nothing. It is an allow policy, so it blocks nothing, but it is still on our org. If you can, try publishing the container policy on its own. I haven't tested that.
Create the block policy as a draft. The body has to be wrapped in {"data": ...}; my first attempt without the wrapper did not create anything.
It answered HTTP 202 with the new policy, "status":"draft" and "policyCoverageLevel":"CONTAINER". Note the policy id it returns. I also created an org-level policy and published both together, copying an earlier test on the same org; I haven't checked whether the publish call needs it. Its body was the same create call with "rule":{"appAccess":{"effect":"allow"}}, "policyCoverageLevel":"ORG" in metadata, and no subject. This is the one from the warning above.
Attach the project. The resource is the project's ARI, with the site's cloudId in it:
bash
1curl-s-X POST \2-H"Authorization: Bearer $ORG_API_KEY"-H"Content-Type: application/json"\3"https://api.atlassian.com/admin/control/v2/orgs/$ORG_ID/policies/$POLICY_ID/resources"\4-d'[{"operation":"ADD","resourceAri":"ari:cloud:jira:<cloudId>:project/<projectId>"}]'
HTTP 202 again. Reading the policy's resources back later showed the project with "applicationStatus":"applied" and resourceKey DSP.
Publish. The API's own description of publishDraftPolicies is that it is "the only way to create or modify published policies":
bash
1curl-s-X POST \2-H"Authorization: Bearer $ORG_API_KEY"-H"Content-Type: application/json"\3"https://api.atlassian.com/admin/control/v2/orgs/$ORG_ID/policies/publishDraftPolicies"\4-d'{"type":"data-security","ruleName":"appAccess","policyOperations":[
5 {"policyId":"<org-policy-id>","action":"UPDATE","policyCoverageLevel":"ORG"},
6 {"policyId":"<container-policy-id>","action":"UPDATE","policyCoverageLevel":"CONTAINER"}]}'
That was at 01:14:13 UTC. A 202 here means accepted, not applied, so the next section is how you confirm it worked.
What a blocked app access call returns to your Forge app
I called the probe again 18 seconds later, at 01:14:31. The block was already in force:
text
1issue 404 {"errorMessages":["Issue does not exist or you do not have permission to see it."],"errors":{}}
2search 200 {"issues":[],"isLast":true}
3dataPolicy 200 {"anyContentBlocked":true}
4dataPolicyProjects 200 {"projectDataPolicies":[]}
The first run of dataPolicyProjects had no ids parameter, which is why it came back empty. Once I added ?ids=11669 and redeployed, the probe at 01:15:25 showed the whole picture:
text
1issue 404 {"errorMessages":["Issue does not exist or you do not have permission to see it."],"errors":{}}
2search 200 {"issues":[],"isLast":true}
3dataPolicy 200 {"anyContentBlocked":true}
4dataPolicyProjects 200 {"projectDataPolicies":[{"id":11669,"dataPolicy":{"anyContentBlocked":true}}]}
5project 200 {"id":"11669","key":"DSP", ...}
To make sure this was the policy and not a permission problem, I read the same issue as myself with basic auth five seconds later:
1{"id":"38309","key":"DSP-1","fields":{"summary":"Blocked-by-policy test issue"}, ...} HTTP 200
So the user sees the issue, the app gets a 404, and the project itself is still readable by the app. Atlassian's events page says the same about containers: "The app is not blocked from accessing data about the container itself, only for all objects within the container, that are restricted by the policy".
Here is what that means for your code, as a short list of what the blocked app received:
Issue read — 404 with the generic "does not exist or you do not have permission" message. Nothing in the body mentions a policy.
JQL search — 200 with "issues":[]. No error, no warning field.
/rest/api/3/data-policy/project?ids=11669 — 200, the project listed with "anyContentBlocked":true.
The last two keep working because Jira's API spec marks both endpoints x-atlassian-data-security-policy app-access-rule-exempt: true. That makes them the one reliable way for an app to tell "the issue is gone" from "I am not allowed to see it". One small trap if you go and read the developer guide: its text names the paths /api/v3/data-policies and /api/v3/data-policies/project, plural. The calls that worked, and Jira's own API spec, are /rest/api/3/data-policy and /rest/api/3/data-policy/project, singular. Use the singular ones.
Which Jira APIs the app access control coverage includes
Atlassian keeps a coverage summary for Jira that splits API actions into blocked and not blocked. Among the blocked ones are "reading work items or their estimations", "listing work items associated with a board" and "getting work items for a sprint". Among the ones that are not blocked are "reading an epic" and development information such as builds, deployments, feature flags and remote links. So a board-based app can see the board and get an empty set of work items on it, which is exactly the silent shape above.
The page is not fully consistent. When I read it on 28 September, "moving work items to or from an epic" and "listing work items in an epic" appeared under both the blocked and the not-blocked epic headings, and board actions have the same problem: "creating or deleting a board" is in a blocked list, while "creating, reading, updating, or removing boards" and "listing boards" are in the not-blocked list. I haven't tested any of these, so if your app depends on epics or boards, probe them yourself the same way.
The Confluence side has matching endpoints, GET /wiki/api/v2/data-policies/metadata and GET /wiki/api/v2/data-policies/spaces, which return the same anyContentBlocked field. My probe was installed in Jira only, so I have not run those.
Subscribe to data security policy events and read the payloads
Blocked events started arriving about a minute after the publish. The first container event carries the time 01:14:13.964 and was logged at 01:15:17.337, a lag of 63 seconds. Pull them with:
bash
1forge logs -e development --since 15m
You should see lines that start with DSP event, which is the prefix the handler logs. Here is one container event exactly as delivered, from the first batch:
A few things in there differ from the documented examples. The source I received was com.atlassian/ecosystem.app_policy on container events and com.atlassian/jira.app_access_rule.event_publisher on objects events, not the atlassian.com/ecosystem value in the docs. The product is lowercase jira. The container id and the issue ids are strings. Don't match on source, and don't parse the ids as numbers without meaning to.
The events reference is clear about which event to act on. The container event "must not be used to determine which objects have been blocked ... this will result in missing data. Instead, you should use the App Access to Objects Blocked event type". For Jira, the only object type in the objects event is issue; for Confluence it is page, blogpost, whiteboard and database. When a lot of objects are blocked, the page says the list is split and "notifications about additional objects are sent in subsequent events". It does not say where the split happens, and with two issues I could not reach it.
The developer guide also warns that apps "can receive a large number of events when registered for object-level data security policy events" and recommends subscribing to them "only if notifications at the space or project level are applicable to your app's use cases." If you only need to show a banner, the container event is enough. If you cache issue data, you need the objects event.
Deduplicate on type, data and time, not on event id
The whole run produced 36 deliveries, 18 of each type, and no other event type. I counted them from the logs:
bash
1forge logs -e development --since 12h -n1000|grep-o'avi:ecosystem[a-z0-9_:.]*'|sort|uniq-c
Each logged event contains its type twice (type and eventType), so that is 18 events of each. The container events came in pairs. The one above, id 3eadef09-... at 01:14:13.964, was delivered a second time as id 15dea3b9-... with the same time, the same data and the same trace id. All nine container event times in the run were delivered twice like that. The objects events never shared a time, but every one of them listed the same two issues.
So a handler that deduplicates on event.id will process each container event twice. Key your dedupe on type, data and time instead. A minimal version with Forge KVS needs npm install @forge/kvs and the storage:app scope added to the manifest, next to read:jira-work, then forge deploy -e development and forge install --upgrade -e development --site <your-site>.atlassian.net --product jira so the installation picks up the new scope. KVS keys only allow letters, digits and :._-# plus spaces, up to 500 characters, so the snippet hashes the payload rather than pasting issue ids into the key:
javascript
1import{ kvs }from'@forge/kvs';2import{ createHash }from'node:crypto';34exportconstonPolicyEvent=async(event)=>{5const digest =createHash('sha256')6.update(`${event.type}|${JSON.stringify(event.data)}|${event.time}`)7.digest('hex');8const k =`dsp-event:${digest}`;9if(await kvs.get(k))returntrue;// duplicate delivery10await kvs.set(k,true);11// ... mark the project or issues as blocked in your own storage12returntrue;13};
The two copies of one container event were logged 26 milliseconds apart, so this check-then-set can still let both through. Make the real work idempotent too: setting a "blocked" flag twice is harmless, sending a user two notifications is not. I haven't run this dedupe version against a real block; the probe only logged. The fields it hashes are the ones in the payloads above.
What happens when you unblock, and what does not
This is where the platform stops helping you, and where my own run got messy.
My first attempt to remove the block, at 01:18:24, was publishDraftPolicies with "action":"DELETE" for both policies. It returned HTTP 202. It did not remove anything. Nine probes over the following minute all got the 404, and a minute later a second batch of 24 events arrived, and they were all blocked events again. Ten and a half hours later both policies still read "status":"published" and the app was still blocked. The API spec lists DELETE as a valid action, so I can't tell you why it did nothing. I ran it once, on one org.
What did work was the v1 delete on the container policy:
No event told the app. I pulled the logs 8.5 minutes after the delete, well past the one-minute lag the blocked events had, and there was not a single new event line. Atlassian's events reference only defines the two blocked types, so this matches the docs rather than contradicting them. Your app has to find out that access is back by asking, and the per-project endpoint is the thing to ask.
The site-level flag did not follow. /rest/api/3/data-policy still said "anyContentBlocked":true after the only block policy was deleted, in every probe for the next minute, while the project-level answer had flipped to false. I don't know why. The only policy left on the org was the published org-level one with "effect":"allow". Whatever the reason, it means the site-level flag is not a safe "all clear". Check the projects your app cares about.
A reinstall does not reset any of this either. At 01:15:49 I uninstalled and reinstalled the probe while the block was live. No events came, the probe stayed blocked, and the web trigger URL did not change. That matches the events page: installs and uninstalls don't fire these events, and an app installed into an already-blocked project will never be told.
One warning from Atlassian's coverage summary that belongs in your design: "Blocking an installed app's access to data ... could result in the installed app deleting the data as no longer required. This data may not be restorable if you unblock the installed app". That is aimed at admins, but it describes a bug in the app. If your sync job reads a 404 or an empty search as "delete my copy", an admin's policy change deletes your users' data. Don't do cleanup on a 404 without first checking the data policy endpoint.
Handle the block in your app
Putting the measurements together, this is the pattern I'd build into a production Forge app that reads Jira data in the background.
1
Treat a 404 as ambiguous
before your app acts on a missing issue, call /rest/api/3/data-policy/project?ids=<projectId>. If anyContentBlocked is true, the issue is hidden, not gone. Don't delete anything.
2
Treat an empty search as ambiguous too
an empty JQL result for a project with blocked content is a policy result, not an empty project. Check the same endpoint before showing "no results".
3
Store the block when the event arrives
on the objects event, mark those issue ids blocked; on the container event, mark the project. Deduplicate on type, data and time.
4
Tell the user it's the policy
the developer guide recommends in-app messaging (a SectionMessage, Tooltip or Tag) that says results may be incomplete and that this is because of "their organization's policy, and not the app itself". Without it, a blocked app looks like a broken app.
5
Poll for the unblock
since no event fires when access returns, re-check the data policy endpoint for your blocked projects on a schedule or the next time a user opens the app, and clear the flag when it reads false.
6
Check on install
because no event fires for policies that existed before install, call the data policy endpoints once when your app first runs on a site.
To verify the pattern, run the probe loop from this tutorial against your own app: block a project, confirm your app shows the policy message rather than "not found", unblock it, and confirm the message clears on the next check.
If your app is already near its API budget, the extra checks have a cost. Our Forge rate limits tutorial covers how the hourly points pool is counted. Checking one endpoint per project, only after a 404 or an empty result, keeps it small. This is the same class of problem as Confluence not firing delete events for pages inside a deleted space: the platform changes what your app can see and does not tell you in a way you can rely on, so you check the state yourself.
App access rule limits and what cannot be blocked
The limits have changed since the feature launched, so check the dates on anything you read. In April 2024 a developer on the Atlassian developer community quoted "15 spaces per policy, 50 policies per organization" and called it "basically unusable except in the tinyest Confluence instances". The current developer guide still describes up to 15 spaces per policy for Confluence, and for Jira "up to 15 projects (containers) per Jira product instance (workspace), per policy", with up to 15 Jira instances in one policy. Atlassian's newer "Block Marketplace and custom app access" support page describes the override differently: "You can block up to 2,000 spaces from up to 2,500 apps." It also says every org admin can block all eligible apps, while allowing some apps and not others needs Guard Standard or Guard Premium.
Some apps can't be blocked at all. Atlassian's list covers system apps, apps in the DevOps ecosystem (described as "currently unsupported"), and Marketplace apps that hold an API token or credentials waiver: 2 for Confluence and 15 for Jira when I read it on 28 September. If yours is one of those, none of the above will happen to it. The full list and the admin view are in our data security policy post.
Two more notes from the support pages that affect testing. Activating a control "replaces your existing live control configuration, which you can't get back", so don't experiment on a production org. And when a new space is added it isn't covered automatically; the admin has to update the control to include it.
Clean up the test policy
Deleting the container policy with the v1 call above restored access and set its status to deleted. The org-level policy was a different story. The v1 delete refused it:
text
1{"timestamp":...,"errors":[{"id":...,"status":"400","code":"ADMIN-400-27","title":"Invalid Action","detail":"Published Org-Wide policies cannot be deleted"}]} HTTP 400
(other top-level fields and the error id trimmed.)
publishDraftPolicies with DELETE returned 202 and left it published, and trying to set it back to draft returned 400. Its effect is allow, which is the default, so it blocks nothing, but I could not remove it through the public API. I haven't found an API route that removes it, and I have not tried the admin UI either. If you follow this tutorial, try leaving the org-level policy out; I haven't tested publishing the container policy by itself.
To remove the probe app when you are done:
bash
1forge uninstall -e development --site<your-site>.atlassian.net --product jira
What you have now
Key takeaways
A Forge probe app that subscribes to both data security policy events and logs their real payloads, plus a web trigger that shows what a blocked app sees.
The actual behaviour of a blocked app on Jira Cloud: 404 with the generic message for an issue, 200 with an empty list for a search, and the project still readable.
Two data policy endpoints, /rest/api/3/data-policy and /rest/api/3/data-policy/project?ids=, that stay available while the app is blocked and tell it what is going on. Trust the project-level one.
Container events arrive in duplicate pairs with different ids, so deduplicate on type, data and time.
No event fires when access comes back or when the app is installed into a site that already has a policy. Poll the project endpoint for both.
Not covered here: the Confluence endpoints and events (my probe was Jira-only), the size at which objects events are split, and why publishDraftPolicies with DELETE returned 202 without deleting anything.
JSM Portal Request Create Property Panel Submit: Types Won't Catch a Bad Payload