Forge context security: which parts can we trust, and why is it so hard to get right?

Forge context security: which parts can we trust, and why is it so hard to get right?

Forge apps receive context in many places: resolver req.context, getAppContext(), trigger payloads, and the Forge Invocation Token (FIT) on Forge Remote calls. For most of these, the docs don’t say which fields can be trusted. Where they do, another page often contradicts it, or what actually arrives contradicts it.

Docs that contradict each other or are missing

Resolver context

  • Forge resolver (updated Nov 2024): “Not all of the values in the context parameter are guaranteed to be secure […] See App context security.”
  • App context security (updated Nov 2025): “Context parameters in each resolver function are guaranteed to be secure, unalterable, and valid to be used for authorization.”
  • The previous version of that page said: “Only license, accountId, accountType, and installContext […] are guaranteed”. Was this an intentional change? Does it cover extension, for example extension.issue.id or extension.project.id?
  • App context security covers only resolvers and getContext. It says nothing about getAppContext(), trigger payloads, or the FIT.

FIT

  • app.module.type is “the module type […] for front-end invocations. Otherwise, it will be core:endpoint”. In practice, invokeRemote from a web-trigger function sends "" for both app.module.type and the required app.module.key (my report, acknowledged as a known gap for all hosted compute → remote calls in FRGE-1933). Endpoint-backed triggers send their own module type (core:trigger, core:scheduledTrigger, core:preUninstall). When is core:endpoint actually sent?
  • principal is documented as “UI modules only”. It is present on backend invokeRemote calls whenever a user is behind them (FRGE-2031, confirmed by staff).
  • Until 25 Sep 2026, backend context was documented as empty, although it contained cloudId. The docs were corrected after a partner reported it (FRGE-2277).
  • The requestRemote page lists no FIT claims. It says the remote “must also verify the contextual claims”, but not which claims exist or can be trusted. Some context is missing entirely: ECO-1678 reports no project context in requestRemote FITs for jira:workflowPostFunction.
  • RFC-138 says each module’s owning team “must analyse and determine which fields have adequate permission checks and should legitimately remain in the signed, trusted context”. Does that mean some fields in today’s signed FIT context lack permission checks? If so, which ones? As highlighted in the RFC, I think this is a step in the right direction, but if it isn’t done holistically done across the platform, it will only fix one small part.

Telling frontend and backend calls apart in a remote

A remote can be called in three ways:

  1. @forge/api invokeRemote from a Forge function, for example, a resolver that has already checked permissions.
  2. @forge/bridge invokeRemote from the browser. It accepts any path and custom headers, sent to the remote behind the module’s resolver.endpoint.
  3. @forge/bridge requestRemote from the browser. It can target any remote in the manifest. Requests “are not proxied through the Forge platform”, so the browser sends the FIT itself.

An endpoint meant only for (1) can be called directly from the browser by any user who can render a UI module. For requestRemote, that includes JSM customer portal modules. I believe there is no documented FIT claim that tells (1) apart from (2) or (3).

In FRGE-1927, Atlassian suggests a workaround for getting trusted context to a remote: “invoke your remote from within the Forge function and pass along the appropriate details with whatever security controls you deem fit”. That only works if the remote can verify that the call came from the Forge function, otherwise, it brings us back to the same gap.

If Atlassian needs more evidence, I am fairly certain a review of submissions in Marketplace partners’ bug bounty programs would show that many come down to uncertainty or assumptions about these context contracts.

Proposal: one context contract

I think it would be great to have one reference page that covers every place an app receives context, and keep it current. Ideally, this will not be hand-crafted but generated from the source of truth:

  • Every surface: resolver req.context, view.getContext() and UI Kit hooks, getAppContext(), trigger and consumer arguments, and the FIT for each caller: @forge/api invokeRemote (per function type), bridge invokeRemote, requestRemote, endpoint-backed triggers, and invokeService.
  • Shape: for each surface, the fields, whether they are required or optional, and how they vary by module (for example, extension).
  • Trust: for each field, whether the platform sets and permission-checks it, so it is safe for authorization, or whether it can be influenced from the browser.
  • Caller: a documented, signed way to tell which surface produced an invocation, at minimum a Forge function versus the browser.
  • Changes: versioned, with changes announced in the changelog, so we don’t discover them from runtime behavior.
  • Coordination: better Forge platform coordination between teams that develop individual modules/context seams to have a more consistent experience

Open questions/requests

  1. Which resolver req.context fields are trusted, including extension. RFC-138 takes a stab at this for requestRemote, but it only covers one small part of all the seams.
  2. How can we tell if a call came from a Forge function or from the browser (invokeRemote or requestRemote)? Or is there a documented claim in the FIT we can rely on?
  3. Document the requestRemote FIT claim set.

Forge is evolving quickly, which is great, but it also means docs, concepts, and contracts must keep pace. We build on Forge every day, and the seams between frontend, backend, and remote have turned into guesswork and trial and error. They are getting harder to get right, not easier.

Sort of thing they should be fully automating with agents by now.

My guess is Atlassian’s microservices architecture for all of their infra is going to bite them hard in this agentic era. 1000s of tiny silos which only made sense in an era when fallible humans were entering each character by hand.

Some updates/new findings from manual testing.

I did some additional manual testing, and it changed parts of the picture above.

In the post I wrote that invokeRemote from a web-trigger function sends empty strings for app.module.type and app.module.key, the known gap tracked as FRGE-1933. It seems hosted functions now send their own module type, such as core:webtrigger. However, FRGE-1933 is still “Reviewing”, and we found no changelog entry. For us, this was a breaking change: an endpoint that accepted the empty type for our web trigger now rejects it. Was this change intentional, and if so, why is FRGE-1933 still open and why wasn’t it announced?

My testing confirmed what I suspected in the post above: apart from context, no claim tells a call from a Forge function apart from a browser call. Backend calls carry only cloudId, while browser calls also carry the module’s details. RFC-138’s trusted-only would shrink browser context too, so the two may look even more alike. That makes a reliable, trusted way to tell a Forge function call to a remote from a browser call to a remote apart all the more important. Until there is, we may need to add our own request signing between our Forge functions and our remote, rather than trusting where a call came from, unless someone can enlighten me on how to do this with what’s there today.