RFC-141: HMAC Authenticated Web Triggers

Project Summary

The RFC proposes a lightweight, industry-standard authentication layer for Forge app developers using Web Triggers. This ensures that functions invoked by publicly accessible Web Trigger URLs are only called by trusted parties.

  • Publish: 6 Aug 2026

  • Discuss:

  • Resolved:

Problem

The Forge Web Trigger capability lets developers create a public URL. When called via HTTP, that URL invokes a backend function in their Forge app. Developers can create these URLs explicitly via the CLI, or programmatically at runtime using the webTrigger module from @forge/api.

Currently, Forge Web Triggers lack native authentication or authorization. Developers must build their own solutions at the application layer, which is error-prone and a common source of security vulnerabilities.

In addition to solving the specific problem of simple, shared-secret authentication, this feature also lays the groundwork to provide a wider variety of more complex built-in authentication mechanisms in the future for developers and admins.

Proposal

We are proposing to support HMAC Authentication for Forge Webtriggers.

This proposal closes 3 key security gaps in the default Web Trigger capability:

  1. Authentication: Only clients with a pre-shared secret key can call the URL.

  2. Body Tampering: Signatures are computed at the client and re-computed at the server to ensure data integrity.

  3. Replay Attacks: Shared secret keys expire and can be rotated. A leaked URL cannot be called indefinitely.

This will be an optional authentication solution, implemented at the platform layer and enabled via modules.webtrigger.request.authentication in your app’s manifest.yml:

# Example manifest.yml

modules:
  webtrigger:
    - key: hmac-auth-webtrigger
      function: auth-webtrigger-fn
      urlFormat: v2 # required for this feature
      request:
        authentication: hmacSharedSecret # valid values include: hmacSharedSecret | none`*

For backwards compatibility, the request field will be optional. Webtriggers that do not have this field defined will continue to work.

Feature Details
URL format Authenticated URLs will use a new v2 format with an /auth/ path.
Key generation Secret keys (base64 encoded) are provided via the Forge CLI (piped or interactive) or programmatically via the @forge/api runtime.
Authentication header Requests must include the x-hub-signature header containing an HMAC SHA256 signature of the body, signed with a base64 decoded secret key.
Key management Supports up to 2 keys per installation for rotation. Keys expire after 6 months. Decoded keys must be between 32 and 256 bits.
# Example Authenticated URL

https://15314aab-9e4a-482e-9db5-fac2eaa17e60.webtrigger.atlassian.app/auth/dUye775SIX5AnlZPckfl2PF5cRs

Note: userPath segments are still supported for authenticated URLs.

URL Generation via CLI

Developers can supply the base64-encoded secret key via an interactive prompt or by piping it to the forge webtrigger create command.

# URL creation via piped input
echo -n "<encoded-shared-secret-key>" | forge webtrigger create --readSecretKey

## OR

# Url creation via interactive input
forge webtrigger create
? Select an installation: my-site.atlassian.net
? Select a web trigger: hmac-auth-webtrigger
? Enter the secret key for this HMAC web trigger: [input is hidden]
<developer types or pastes encoded secret key>

URL Generation via Runtime

The webTrigger module in @forge/api will be updated to accept the encoded shared secret key.

import { webTrigger } from "@forge/api"

async function createHmacAuthWebTrigger() {
    const forceCreate = true;

    // You can still force create urls with the existing API
    // const url = await webTrigger.getUrl('simple-webtrigger', forceCreate);

    const url = await webTrigger.getUrl('hmac-auth-webtrigger', {
        forceCreate,
        secretKey: '<encoded-shared-secret-key>'
    });

    return url;
}

URL HTTP Authentication

The x-webtrigger-signature will be required for requests to webtriggers that use authentication: hmacSharedSecret.

We chose x-webtrigger-signature because:

  • Using the Authorization header could conflict with existing Web Triggers.

  • It conforms to the x-hub-signature header shape used by contemporary industry solutions, but clearly linking the header to the Forge Web Trigger capability.

  • Atlassian already uses this approach in product webhooks such as Jira.

Clients compute a HMAC SHA256 signature of the HTTP body, using their decoded secret key. This signature is sent in the x-webtrigger-signature header. The server recomputes the signature and compares it to the header value. Requests only succeed when the signatures match.

# Example curl request

signature=$(echo -n <request-body> | openssl dgst -sha256 -hmac <your-decoded-secret-key> | sed -E 's/^.*= /sha256=/')
# The above 'openssl dgst -sha256 -hmac' command produces a signature of the shape: SHA2-256(stdin)= 8adf40a8b889640b2235c626e988ad3c2c328bd4e6a02860a7418f162e300c92
# The header value provided with the request should replace the prefix 'SHA2-256(stdin)= ' with the standard prefix 'sha256='.
# So, the value provided is 'sha256=8adf40a8b889640b2235c626e988ad3c2c328bd4e6a02860a7418f162e300c92'

curl -X POST \ 
https://15314aab-9e4a-482e-9db5-fac2eaa17e60.webtrigger.atlassian.app/auth/dUye775SIX5AnlZPckfl2PF5cRs \
-H "x-webtrigger-signature: $signature" \
-d <request-body>

Replay Prevention

Keys expire after 6 months once registered. During this period, if an attack can get ahold of an inflight request body and signature header, they can repeat this request until the key used to compute the signature expires.

Timestamps

Optionally, HTTP invocations can provide an additional header x-webtrigger-timestamp, with the RFC 3339 formatted timestamp of when the request was performed. If the request contains this header, the signature should be computed with the timestamp and body of the request, formatted as <timestamp>.<body>,

At the server, for requests that provide the x-webtrigger-timestamp header, the server will recompute the signature using the same format. It will also ensure that the timestamp provided is within 10 minutes, either side. This ensures that leaked requests are only valid for 10 minutes once made.

Requests without Data

GET requests, or any request with no body, should compute the digest using an empty string. The server uses the same approach when authenticating these requests. It is recommended that these requests, if possible, use the Timestamps feature to prevent indefinite replay attacks of their request (until key expiry).

Secret Keys

A webtrigger module can have at most 2 secret keys registered per product installation. This supports key rotation.

Creating a new URL with the same context (module, installation, app, and environment) and a new secret key will replace the oldest stored key. Both registered keys are tried at invocation time when comparing signatures.

Security

Decoded secret keys must be between 32 and 64 bytes long. This range ensures secure signature generation. Key length has no effect on signature generation time, at either the client or server, however providing keys longer than 64 bytes is useless as they will be truncated to 64 bytes during computation.

Expiry

Keys expire 6 months after registration. Once a key is within 30 days of expiry and is used to compute a signature for an authenticated request, the response of that HTTP request will contain a header x-webtrigger-key-expires with RFC 3339 standard date of the key expiry as the value.

# Example
x-webtrigger-key-expires: 2026-08-13T16:40:05Z #format: YYYY-MM-DDTHH:MM:SSZ

Hash Function Enforcement

x-webtrigger-signature header value must be prefixed with sha256=. The server will use this to determine the cryptographic hash function to perform the signature generation with. As part of this proposal, only sha256 is supported.

Asks

  • Like or comment on this RFC if this feature would benefit you or your apps.
  • Do you have feedback on the design or the proposed APIs?
  • Is there anything we are missing that we could reasonably include?
  • Does anyone require a method to determine the age of their registered keys?

RFC Update Log

13 Aug 2026

  • Updated minimum bit length of decoded secret keys; 32 bits → 32 bytes.
  • Updated maximum bit length of decoded secret keys; 256 bits → 64 bytes.
  • Added optional timestamp feature, to strengthen replay attack prevention.
  • Added expiry header in HTTP response, to indicate a key is close to expiry.
  • Added sha256= prefix to signature header value.
  • Consolidated all header values with prefix x-webtrigger-*.

Thanks for the RFC — the HMAC approach looks solid, and the 2-key rotation window is a good foundation.

One thing I’d like to raise: as far as I can tell, generating or replacing a secret key always requires the Forge CLI or the @forge/api runtime. Web Triggers are very commonly used for integrations between systems, and those integrations would benefit a lot from being able to rotate keys automatically — specifically, using a currently valid key to authorize the creation of the next one, without a human running the CLI or a separate privileged runtime call.

Concretely: could the platform support an authenticated rotation flow where a request signed with an existing valid key is allowed to register a new key (which then replaces the oldest, as already described)? That would let two systems roll their shared secret on a schedule with no manual intervention, while still honoring the 2-key limit and 6-month expiry you’ve outlined.

This connects to your open question about determining the age of registered keys — key-age visibility is really a prerequisite for automated rotation. To roll keys safely on a schedule, a system needs to know how old each key is and when it’s due to expire, so it can rotate ahead of the 6-month cutoff rather than reactively. So I’d say yes, an API to read key age (and ideally expiry) would be valuable, and it pairs naturally with the auto-rotation flow above.

Is auto-rotation something you’d consider supporting, either in this RFC or as a fast follow?

That sounds a lot like an OAuth refresh token flow :thinking:

True, token-refresh or auto-key-rotation are common patterns.

Hi there,

thanks for this - authentication at the platform layer rather than in every app is very welcome. Mostly questions.

Key length: the floor is 32 bits, and the RFC says the range “ensures secure signature generation”. Have I read that right? RFC 2104 §3 suggests at least the hash output length, and NIST SP 800-224 ipd puts the minimum at 128. Since key length doesn’t affect signing time, could the floor go up?

Signature format: Jira sends X-Hub-Signature: sha256=<hex> per WebSub; here the prefix is dropped. Deliberate? It leaves no non-breaking path to another algorithm, and existing x-hub-signature libraries reject a bare digest on length before they verify.

Replay: would an optional timestamp in the signed string, Stripe-style, be in scope? With an empty body the digest is constant per key, so on GET the header is effectively a fixed token.

Expiry and failures: what happens at six months: hard fail, grace period, warning? And does a bad signature respond differently from a bad URL token?

Runs on Atlassian: my reading is that this is orthogonal, since the RoA rule keys off response.type and inbound auth isn’t in the criteria. Is that right? And would request.authentication ever become a criterion?

Small one: the curl example passes the decoded key as a shell argument to openssl -hmac - a key containing a NUL byte is silently truncated and the signature comes out wrong with no error.

Cheers,
paul

Hi @markrekveld,

Thank you for your engagement with this RFC!

I agree that it would be ideal if keys can effectively be rotated without subsequent calls to authenticated Forge API’s (ie. URL creation) , making it easier for third-party client integrations to use this feature.

One trivial solution for this could be that we introduce some signal to the web trigger function handler payload, indicating that a key is about to expire. The function could then re-generate it’s own secret-key and provide it to the calling client in the response/headers. However, this partially defeats the purpose of the platform authentication layer, requiring apps to explicitly handle rotation.

I think the problem of supporting key rotation by only authenticated clients is non-trivial, without impacting the security posture of the feature and potentially enabling an attacker to indefinitely rotate their/your keys and overtake your function. This while also trying to maintain the low-touch, lightweight authentication that we intend on providing without going down the rabbit-hole of a complete OAuth solution, which is out of scope for this proposal, but something we do want to address.

I will continue to think on this issue and come back to you, as I agree that this ability would be an immediate improvement.

As for the request for key-age and expire; agreed and noted. I will amend the proposal to include these details, most likely as headers in the response to HTTP requests to /auth web trigger urls.

Thanks, Matthew.

Hi @ppasler,

Thank you for your comment and engaging with this RFC!

In order of your questions;

Key length: The floor could absolutely be raised. 32 bits is the shortest key length that is technically possible, however not the most secure. A simple update would be to match the hash length, so a minimum of 256 bits, and support up to the size of the hash block, 512 bits. Anything larger than that would be hashed down anyway. I will amend the proposal with this. Thanks!

Signature format: You are correct, the prefix being dropped was deliberate. This was to simplify the overall HTTP payload and avoids any possible syntax, encryption or formatting issues. Amending this to support the prefix, in accordance with both Jira and other platforms, shouldn’t be an issue however.

Replay: I see your point about the signed empty, eliminating the use of this feature. Initially we did not want to support timestamp signing to encourage adoption of this feature initially without implementing too many security hoops to jump through, however this would be a hole we need to patch. I will get back to on an approach for timestamp signing and strong replay prevention beyond simple key cycling.

Expiry and failures: At 6 months, assuming no stored unexpired keys, requests will be hard rejected with status: 401 in the response. The response body however will differ based on the reason for rejection, for example an expired key vs. an invalid signature.

Runs on Atlassian: I will speak to my PM about this and get back to you as I don’t want to speak out of turn or misrepresent intentions.

Fair point on the curl example, I will amend this as well.

Thanks again, Matthew.

I wouldn’t include any rotation solution in the normal flow or request handling. Instead, go with explicit endpoints specifically built to support key-rotation.

I agree, but I also think that taking this into account now, makes it less-impactful to implement then being implemented as an afterthought.

As my mind always drifts into solution thinking, some that come to mind include:

  • Only supporting auto-rotation X days before the key expires.
  • Have an async flow where a site admin needs to approve the rotation request before the app can get the new key.
  • Only support auto-rotation is there is only 1 key that is about to expire (this will require expired keys to be auto deleted)

Happy to jump on a call to discuss ideas if you want.

Lovely. One additional note is that may not need to be in every single response. There is likely a window of time where this information is useful, like the last X days before the key expires.

Hey,

I have updated the RFC with some extra details, requested features and clarifications, documented under the Updates Log section at the bottom.

We are still exploring possible solutions for key rotation without using the key cycling method.

I can confirm that this feature is not, and will not be a requirement for Runs on Atlassian.

Thanks, Matthew.