back to research
LARP-2026-015web14 min read

Jira admin rights checked once at token mint and honoured after offboarding

A Jira Cloud app on Forge took the platform's context token as proof that the caller was a Jira admin, and Jira only checks that when it mints the token. For the token's 900 seconds, an offboarded account that Jira already answered with 401 could rewrite the status sets that decide how recorded engineering time is reported.

The app records how long every Jira issue spends in each workflow status and reports that time as Active, Blocked or Wait, depending on which status set an administrator has put each status in. Teams that split capital from operating expense feed the Active figure into that split. It runs on Atlassian Forge, so its settings screen is a Forge admin page and its backend is a set of Forge resolvers that the page calls by name.

For any app that lives inside someone else’s permission model, I start with the same question. Who decides that the caller is allowed, and at what moment?

Forge Custom UI and resolvers

It takes two requests. First the page’s Custom UI asks Jira for a context token for its own module:

httptoken mint, descriptor cut to the two fields that matter, identifiers replaced
POST /rest/internal/2/forge/context/token

"extension": {
  "id":   "ari:cloud:ecosystem::extension/<app-id>/<env-id>/static/<settings-module>",
  "type": "jira:adminPage"
}

Jira enforces the admin requirement of an admin page module at this point. A non-admin is refused straight away:

jsonmint as a non-admin, HTTP 400
{"errorMessages":["The user must be a Jira admin to call this extension"],"errors":{}}

An admin gets a signed token. I read its lifetime off its own claims instead of assuming it: iat and exp are 900 seconds apart, and the token carries the caller’s accountId. The page then calls a resolver through Atlassian’s GraphQL gateway, passing the token along:

textresolver call, persisted-query hash removed, resolver names changed
POST /gateway/api/graphql/pq/<hash>?operation=useInvokeExtensionRelayMutation

payload.call.functionKey   read_settings
payload.call.payload       { ...arguments... }
payload.contextToken       <the token from the mint>

The token’s job is to say, under Atlassian’s signature, which user is calling from which module of which installation. Whether that user may change the installation’s settings is for the app to decide.

Authorization at mint time

Atlassian asks whether the caller is a Jira admin when it mints the token. The app never asks again. Every resolver behind the settings module served any valid token for that module, so the answer Jira gave at mint time held until exp.

This is the known cost of self-validating tokens. A server that checks a bearer token’s signature and expiry and nothing else can’t learn that a grant was withdrawn. Revocation happens at the authority (here, Jira group membership), and the token in the caller’s hand doesn’t change when it does. There are two published remedies: keep lifetimes short, or ask the authority again. Atlassian already does the first, at 900 seconds. The second can only be done by the app.

The backend does validate things. The max-hours writer re-checks its numeric range on the server and refuses 24 with its own error. The set writer refuses a set name whose root it doesn’t recognise with Not Found. The subscription field, which decides whether the paid finance tier is on, can’t be written at all. All of that runs on the vendor’s backend. So the server checked its inputs carefully and never checked whether the caller was still an admin.

Demotion test

I used two accounts on a Jira site I own: A, an admin, and B, the account that gets demoted. Step 1 is test setup. In the real case B has been an admin for months, and nothing unusual happens until step 4.

texttrial 1 of 3, every call after step 4 made as B, resolver and key names changed
1  A adds B to jira-admins-<site>                           HTTP 201
2  B mints a settings-module token                          HTTP 200, exp = iat + 900
3  B  read_settings with that token                         success           positive control
4  A removes B from jira-admins-<site>                      HTTP 200
5  B mints a fresh token                                    HTTP 400          "The user must be a Jira admin ..."
6  B replays the step 2 token:
     write_status_set {"name":"capex_sets/zzzrevoked","newSet":[]}    success
7  B  read_settings, same token:
     "capex_sets":{"types":[],"zzzrevoked":[]}               the key is there
8  B  delete_status_set on the same name, then read_settings: gone

Steps 5 and 6 are the finding. Seconds apart, Jira tells this account it is no longer an administrator and the app accepts its write to the organisation’s configuration. Step 5 also rules out replication lag: the removal is already in force when Jira refuses the mint.

The verdict rests on the separate read in step 7. A write call’s own ok is the server’s claim, and the readback is what shows the key landed. zzzrevoked is in no schema and no client code path sends it, so nothing else could have put it there. A prover written for this one sequence ran it three times, with the controls inside every trial: positive control 3 of 3, fresh mint refused 3 of 3, write accepted 3 of 3, readback carrying the key 3 of 3, key gone after cleanup 3 of 3.

Offboarding test

Demotion is the mild case. Real offboarding also removes the person from the licensed users group, and at that point people assume the door is shut. I ran the sequence again with both removals:

textfull offboarding, trial 1 of 3
A  removes B from jira-admins-<site>       HTTP 200
A  removes B from jira-users-<site>        HTTP 200
B  GET /rest/api/3/myself                  HTTP 401  Client must be authenticated to access this resource.
B  fresh mint                              HTTP 400  The user must be a Jira admin to call this extension
B  read_settings, step 2 token             success
B  write_status_set, marker key            success
B  read_settings, marker present           success
B  delete_status_set, marker gone          success
A  puts B back in jira-users-<site>        HTTP 201

Jira no longer authenticates the account, and the app still reads and writes for it. That held in all three trials. There’s nothing further an administrator can revoke inside Jira or inside the app. The app keeps no session list and no log of settings writes, so the remaining admin has nothing to look at while it happens.

Controls

A leftover browser session could explain all of this, so I tested that directly. These are the controls, all run as B:

request answer
the same call with the token left out Missing Forge Context Token, errorType EXTENSION_INVOCATION_RESTRICTED
the same call with a token for the issue panel module, which any licensed user can mint Resolver has no definition for 'read_settings'
the step 2 token replayed 867 seconds after mint success
the step 2 token replayed after exp last success 16 s after exp, first Token has expired at 46 s

The first two rows show the session on its own reaches nothing, and that a token B is still entitled to lands on a resolver function with no settings handlers. The only thing B holds that an ordinary member doesn’t is a settings token minted before the removal. The last two rows bound the window. It is the token’s lifetime, plus a few seconds of what I read as ordinary clock-skew tolerance. I didn’t treat the leeway as a second bug.

jiraapp0 sB mints a settings-module tokenJira checks admin rights here, oncethenA removes B from jira-admins and jira-usersJira to B: fresh mint 400, GET /myself 401app to B, step 2 token: settings read and writtentime compressed867 sreplayed token still accepted900 sexpiat + 900, read off the token itself916 slast success, clock-skew leeway946 sfirst "Token has expired"Jira says no.
The app says yes.
acceptedrefusedaccepted after exp
fig. 1One token's lifetime, as seen from both sides. Jira refuses the account from the moment it is removed. The app accepts the token it already holds until the lifetime runs out. Time is compressed between the first few seconds and 867 s.

The precondition is a token the organisation issued to this person for their job. A departing admin gets one by opening the settings page on their last morning, and can use it afterwards from any network.

Impact on recorded time

A settings write matters in this product because of what the sets mean. The app computes each issue’s Active, Blocked and Wait time from the issue’s status history, putting each interval in the bucket its status belongs to. Time in a status that is in no special set counts as Active.

So I pointed the write at the classification itself. I read the status id of an issue that had accrued Active time (GET /rest/api/3/issue/<id>?fields=status gave 10001), offboarded B, and had B put that status in the blocked set. A, the admin who was still in post, read the issue’s time panel before and after:

textone issue, panel read by A, write made by B after offboarding, names changed
A  time panel     Active 1 hour 15 mins | Block 0 mins | Wait 0 mins      baseline
   B removed from both groups, myself 401, fresh mint 400
B  write_status_set {"name":"blocked_statuses","newSet":["10001"]}        success
A  time panel     Active 0 mins | Block 1 hour 16 mins | Wait 0 mins
B  write_status_set {"name":"blocked_statuses","newSet":[]}               success
A  time panel     Active 1 hour 16 mins | Block 0 mins | Wait 0 mins      restored

The extra minute is real elapsed time, because the issue stayed in that status while the trial ran. It’s also why the three trials have baselines of 1h15, 1h16 and 1h17. All three moved and all three came back. Pointing the same call at the wait set reports the time as Wait instead, which I measured on my admin session, so the attacker chooses where the time goes.

Two properties make this worse than it looks. The figures are recomputed from Jira history whenever they’re read, so the change is retroactive: time that was already recorded changes meaning. And the same account can put the set back afterwards, which leaves the admin interface showing exactly what it showed before.

Later I measured how far one write reaches, because the write changes the ruleset and the ruleset applies to every issue in the enabled project. I put nine issues in the same status and read each one from the admin’s panel:

textaddendum, nine issues in status 10001, figures from A's panel
                          baseline              after one write             after B reverts
2 issues, each            Active 2d 10h 21m     Active 0, Block 2d 10h 22m  Active 2d 10h 23m
7 issues, each            Active 10m            Active 0, Block 11m         Active 12m
project Active, minutes   7072                  0                           7090

One request from an account Jira answered with 401 took the project’s Active time from 7072 minutes to zero. Every issue that had time moved. The extra 18 minutes after the revert is the clock again. The controls ran in the same pass: the identical write succeeded while B was entitled, and the fresh mint after offboarding was refused.

Resolver input handling

I mapped the parameter handling from an ordinary admin session, because that shows how a resolver validates its input, which has nothing to do with who is calling. Then I fired the calls that carry the impact as the revoked account, with their own controls.

The set writer checks only the first segment of the set name. That’s because the app’s own client sends two-segment names such as capex_sets/types, so any legal root carries whatever comes after it:

textwrite_status_set name handling, admin session, names changed
{"name":"zzz/types"}                                   Not Found        unknown root, refused
{"name":"capex_sets/zzz"}                              success          known root, invented tail
{"name":"done_statuses/zzz"}                           success          read_settings then shows done_statuses ["zzz"]
{"name":"capex_sets/../../enabled-projects/10000"}     {"ok":true,"settings":null}
     read_settings afterwards: capex_projects ["10000"] became []

done_statuses is a list of status ids everywhere else in the product, and after that write it holds the literal zzz. The admin screens read the keys they know by name, so nothing in the product shows the admin these keys or offers a way to remove them. The last row is a different problem. Path parameters go into the backend request path without encoding, so a set name can climb out of its own route and perform another resolver’s backend operation. Here it cleared the capitalised-projects list, which the set writer should never touch.

Two other resolvers, the project-enable and project-capex writers, take the HTTP method from the caller as well as a path segment:

textset_project_enabled, verb and path from the caller, names changed
{"projectId":"../../settings","method":"GET"}     success, returns the app backend's own settings document
{"projectId":"10000","method":"GET"}              Method Not Allowed
{"projectId":"../../api-key","method":"GET"}      Forbidden

The second row is the control showing the verb I send is the verb used, since the real route has no GET. The third shows the finance-tier gate on the backend route held. The first row makes the app send a request of my choosing to its own backend, with its own credentials, and hand me the body. It succeeded 3 of 3 as the demoted account, in the same trials as the write above. The capex writer’s traversal also succeeded 3 of 3 under revocation, in a separate run.

Then there’s the reset. reset_all_settings takes no arguments at all: the client calls invoke('reset_all_settings') with nothing, so there is no object to scope it to. The vendor’s own settings screen calls the action irreversible and puts a two-step confirmation in front of it. Inside the window it is one request. I never sent it.

Failed attempts

Not every write I tried worked, and the report said which ones failed.

  • I tried to flip a project’s capitalisation flag by sending DELETE on a real project through the capex resolver. As the offboarded account it returned HTTP 500 in 3 of 3 trials, while the identical call as the admin succeeded. I didn’t claim it.
  • Writing the blocked-field setting, a key no client call site writes, was refused with Not Found. The field whitelist held.
  • The reset was never fired, because the program asked for non-destructive testing. Its impact is argued from its signature and the vendor’s own description, and I said so.

On the read side the gain is small. In the demotion case everything B reads afterwards is something B could have copied a few minutes earlier. In the offboarded case the read is a real gain, but what comes back is project ids, custom field ids and status sets. The damage is in the write.

Proposed fix

Ask Jira on every privileged call. The app already declares the read:app-user-token and read:jira-user scopes, so the customer consented at install to the app acting as the invoking user. A call to GET /rest/api/3/mypermissions?permissions=ADMINISTER as that user, refusing unless havePermission is true, gives a current answer for one request per call.

Where the check goes matters as much as the check. Here is a sketch. It’s my code, written for this post, and the vendor’s code may look nothing like it:

jssketch, my code: one check in front of every resolver
import Resolver from '@forge/resolver';
import api, { route } from '@forge/api';

const resolver = new Resolver();
// resolver.define('read_settings', ...), resolver.define('write_status_set', ...), ...

async function requireJiraAdmin() {
  const res = await api.asUser().requestJira(
    route`/rest/api/3/mypermissions?permissions=ADMINISTER`);
  if (!res.ok) throw new Error('Forbidden');          // an offboarded user may get an error here, deny it
  const body = await res.json();
  if (body?.permissions?.ADMINISTER?.havePermission !== true) throw new Error('Forbidden');
}

const dispatch = resolver.getDefinitions();
export const handler = async (...args) => {
  await requireJiraAdmin();
  return dispatch(...args);
};

The wrapper passes its arguments through untouched, so it doesn’t depend on the exact shape Forge hands the function. It fails closed on anything other than a clear yes, which matters because a user Jira has stopped authenticating may get an error back here instead of a clean false.

I also asked for three smaller changes in the same resolvers: percent-encode every client value before it becomes a path segment, stop taking the HTTP method from the caller (each of those two resolvers has exactly two legitimate verbs), and validate set names against the full list of legal keys.

Retest

When the fixing release shipped I retested it. The first thing I found was a trap for anyone doing that. The release renamed the settings module key and its resolver function. The module key is part of the extension ARI, so a harness built against the reported release mints for a module that no longer exists and is refused for that reason alone. That refusal looks exactly like a fix. I recaptured the descriptor from the running app’s own traffic, then had a sitting admin read successfully with the new harness before I concluded anything.

Then I offboarded B again and replayed B’s token against every resolver I could reach. All three trials agreed:

call made by the offboarded account fixing release
read_settings refused, Unexpected end of JSON input
write_max_hours, value already stored refused, same error
set_project_enabled, project already enabled refused, same error
set_project_capex, flag already set refused, same error
delete_field refused, same error
write_status_set, marker name Unknown set.
delete_status_set, marker name Unknown set.
write_field, marker name Not a custom field.
set_project_enabled with ../../settings and GET Not a project id.
list_statuses, list_custom_fields, list_issue_types, list_projects answered

read_settings is refused to this account, while the admin reads it successfully in the same run, and the four writes after it fail with the same error. The next four were stopped by new input validators: set names are now checked in full, and the project id must be a project id. The marker name and the verb-and-path call were both accepted in the reported release and are both refused now. My retest comment said every write I could reach was refused. Reading the rows again, I can only claim something narrower. The validators answered first on those four, and that tells me nothing about whether a caller check stands behind them. I also didn’t send a valid status-set write in the retest, which is the write that moved recorded time.

The four list resolvers still answered a token held by an account Jira answered with 401. I wanted to know if they returned a view scoped to the caller, so I called each one as B and as the sitting admin, minutes apart in the same run, canonicalised the responses and hashed them:

textretest, hash of each canonicalised response (16 hex digits), names changed
resolver             admin              offboarded B
read_settings        1aae925facc996cf   refused
list_projects        4f53cda18c2baa0c   4f53cda18c2baa0c
list_statuses        a0af9a36fa1ff73d   a0af9a36fa1ff73d
list_custom_fields   96c82d092198409b   96c82d092198409b
list_issue_types     55e8a0aa35599f41   55e8a0aa35599f41

The offboarded account gets the admin’s own view, byte for byte: workflow statuses including custom ones, the custom field inventory with ids and schema types, and the issue type hierarchy. list_projects returns an empty list to both on my instance, so nothing is disclosed through it there. I reported it as the part of this report that was still open.

The detail from the retest that I’d keep is list_issue_types. It doesn’t exist in the reported release. It was added in the fixing release, the same release that added the caller check, and it shipped without it. That tells me the check went in handler by handler, so it protects only the handlers someone remembered. The reads were left out, which fits, because the report named the writes and that’s where the attention went. A single check at the point where an invocation enters the app, before dispatch to any function key, closes these four and every resolver added after them. That’s what the wrapper in the sketch above does.

The vendor replied that the remaining read resolvers close in the next deployment. Nothing in this post covers that deployment.

Disclosure

Reported to the vendor and tested on my own Jira site with two accounts I control, then fixed and retested. Every state change was read back and restored, and the reset was never sent. The app, vendor, site, account ids, group ids, extension ARIs, persisted-query hash, backend host and release numbers are removed, and the app’s resolver and settings key names are replaced with names that describe their role.

Zx41R
back to research