Why does POST /rest/api/3/expression/eval lead to HTTP 403 when called from a Forge backend?

I have a Forge backend function that calls POST /rest/api/3/expression/eval using api.asUser().requestJira(). Unfortunately, the API keeps returning

403 OAuth 2.0 is not enabled for method: POST /rest/api/3/expression/eval?expand=meta.complexity

My manifest.yml has the following scopes, which include the ones listed in the API docs for expression evaluation:

    - 'storage:app'
    - 'read:jira-work'
    - 'read:jira-user'
    - 'manage:jira-project'

Can anybody explain what is going on here? Some posts seem to indicate that specific API methods have to be enabled for OAuth2, but I do not understand why the API docs list OAuth2 scopes then?

As a side note, I have also tested calling POST /rest/api/3/expression/eval from a Forge Custom UI directly, and that seems to work just fine, but it is not what I am looking for.

Update: I managed to get this halfway working and narrow the problem scope.

The problem seems only to occur if I include the ?expand=meta.complexity at the end of the URL. If I remove it, it seems to work fine.

I believe this has to do with how the route template function encodes the URI parameters. If I inspect the app logs, the error looks like this:

  "errorMessages": [
    "OAuth 2.0 is not enabled for method: POST /rest/api/3/expression/eval%3Fexpand=meta.complexity",

If I make a request as follows, I can reproduce the issue:


Still not quite sure where things go wrong here, but at the moment, it feels the route template function may be the culprit.

Interesting! I’ve just tried to reproduce it and have valid response from the server. Tried on both new apps and outdated once (to spot a regression if any). Could you please try whether this simple expression for you?

const response = await api.asApp().requestJira(route`/rest/api/3/expression/eval?expand=meta.complexity`, {
    method: 'POST',
    headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
    body: JSON.stringify({
        "expression": "issue",
        "context": {
            "issue": {
                "key": "CS-15" // <- update this
            "project": {
                "key": "CS" // <- update this

return response.json()

My scopes are:

- 'storage:app'
- 'read:jira-work'
- 'read:jira-user'
- 'manage:jira-project'
- 'read:jira-expressions:jira'

At this point I have no idea why that additional query param breaks things for you. Let’s see whether complexity lowering and scope change makes any difference.

Edit: asUser() works as well.


Closing this issue. This is an implementation problem on our side (though I think the Forge API is trying to overachieve unnecessarily).

We are using a generic HTTP client that can be used to make requests via Custom UI bridge’s requestJira as well as via the Forge API’s requestJira. The client accepts a fetch function with the standard fetch API signature to run requests. Unfortunately, the Forge API’s requestJira changes the standard fetch API signature by trying to enforce the usage of the route template function.

To work around this inconsistency, we created a wrapper around the Forge API call like so:

const userFetcher = (url: string, init: RequestInit) =>
    .requestJira(route`${url}`, init);

This calls encodeURIComponent on the whole URL string instead of just the injected template parameters, which in turn modifies the URL we intended to call.


const url = encodeURIComponent('/rest/api/3/expression/eval?expand=meta.complexity')
// => '%2Frest%2Fapi%2F3%2Fexpression%2Feval%3Fexpand%3Dmeta.complexity'
const url = `/rest/api/3/expression/eval?expand=${encodeURIComponent('meta.complexity')}`
// => '/rest/api/3/expression/eval?expand=meta.complexity'

For us, the solution is to stick to assumeTrustedRoute. This reinstates the standard fetch API behavior and interface. Of course, in this case, we have to handle the encoding of injected parameters ourselves - but everyone using a standard fetch API will have to do this anyways.

In my opinion, the Forge API is trying to overachieve by deviating from the standard API signature. It would be better to stick to standard APIs and raise awareness of the potential security issues in the docs. The current solution can lead to quite unexpected results. I wasted a fair amount of hours trying to debug this issue, not to mention the hours Atlassian staff spent looking into this.


Thanks for your help @vpetrychuk! I managed to figure it out (see post above :point_up: ). The initial error message led me down the wrong path. After reading some other posts and my own experience, I feel it’s pretty safe to say that if you get a 403 when calling an API via OAuth2 and get back the message Auth 2.0 is not enabled for method it is likely that it is an implementation issue.