A cross-tenant replay bug in trigger.dev, and what a route's own siblings tell you about it

TL;DR
trigger.dev has two routes that “replay” a task run — re-run it with an optionally-modified payload in an environment you choose. The dashboard route, resources.taskruns.$runParam.replay, ran its action handler with no authentication at all. The HTTP API route, api.v1.runs.$runParam.replay, authenticated the caller’s API key but looked the run up by a globally-unique friendlyId without constraining that lookup to the caller’s own runtime environment. Both funnel into a shared ReplayTaskRunService that accepts an attacker-supplied destination environmentId and never checks that it belongs to the run’s project.
Net effect: a remote attacker who knows a victim run’s friendlyId can clone that run — payload, task identifier, tags — into an environment they own and read it from their own dashboard. Cross-tenant payload disclosure, plus a queue-and-billing integrity angle when the attacker points the replay back at the victim’s environment.
Disclosed as GHSA-9fq3-68cw-r7p2, high severity, no CVE assigned. Fixed in trigger.dev 4.5.2 as part of a bundled security release (PR #4199, merged 2026-07-09). The advisory credits several independent reporters who found the same class inside the same window — I was one of them.
The bug is a plain missing-authorization IDOR. The part worth writing down is the method that found it, because it’s the same shape as the Vaultwarden branch-divergence bug from a few weeks ago, and because the fix for it was already written — it was sitting one file over.
Why trigger.dev
trigger.dev is an open-source background-jobs platform: you write long-running tasks in TypeScript, they run on trigger.dev’s workers, and you observe them from a dashboard. It’s multi-tenant by construction — organizations, projects, and within each project a set of runtime environments (development, staging, production). The entire security model rests on one invariant: a request authenticated for tenant A must never resolve, mutate, or observe an object owned by tenant B.
That invariant is exactly the kind of thing that erodes one route at a time. The webapp is a Remix app with a large surface of api.v1.*, api.v2.*, resources.*, and _app.* routes, each of which re-implements “who is asking, and what are they allowed to touch.” There is no single choke point that enforces tenancy; each route is responsible for its own scoping. When that’s the model, the interesting question isn’t “is the app secure” — it’s “which route forgot.”
The replay feature is a good place to ask that question because replay is unusually powerful. It doesn’t just read a run; it takes an existing run, an arbitrary payload, and a caller-chosen destination environment, and it creates a brand-new run from those parts. A replay route that gets its scoping wrong isn’t an information leak bolted onto a read endpoint — it’s a general-purpose “copy this object into a place I control” primitive.
The audit workflow
The method here is what I’d call sibling-divergence, and it’s a cousin of the branch-divergence audit I used on Vaultwarden. The idea: when a codebase implements the same operation shape across many routes, each written by a different patch at a different time, the routes drift. One picks up a guard; its neighbor doesn’t. The neighbor isn’t wrong the day it lands — it becomes wrong the moment the shared expectation (“every route in this family scopes to the tenant”) solidifies around it and it doesn’t get updated.
So I didn’t start by reading the replay route in isolation. I asked Claude to enumerate every route under apps/webapp/app/routes/ that acts on a single task run identified by a path parameter — replay, cancel, reschedule, the tag and trace and span endpoints — and for each one, to extract two things: does the action/handler authenticate, and does the Prisma lookup for the run constrain by runtimeEnvironmentId or an organization-membership join. The output is a small table, one row per route.
Most rows looked identical: authenticate, then findFirst with a where clause carrying either runtimeEnvironmentId: authenticationResult.environment.id (the API routes) or project: { organization: { members: { some: { userId } } } } (the dashboard routes). Two rows didn’t. The dashboard replay route’s action had no authentication in its column at all. The API replay route authenticated but used a bare findUnique({ where: { friendlyId } }) with no tenant constraint.
That’s the whole signal. Everything the two vulnerable routes should have done was already written, correctly, in their immediate neighbors. The table just made the two blank cells impossible to miss.

The part I want to be honest about: the table is where the model earns its keep, and the confirmation is where it will lie to you if you let it. “Route X doesn’t authenticate” is a claim the model states just as confidently when the auth is actually happening in a middleware it didn’t read as when the auth is genuinely absent. So the blank cells are leads, not findings. I read each of the two routes end to end, then went looking for a global auth layer that might be covering them — the Remix server.ts middleware chain, the rate-limiter path matchers, the entry handler — and confirmed there was nothing in front of /resources/* that authenticated. Only then was it real.
The bug
Finding 1 — the dashboard replay action never authenticated
apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts. The route exports both a loader and an action. The loader calls requireUser. The action — the handler that actually performs the replay on POST — does not:
export const action: ActionFunction = async ({ request, params }) => {
const { runParam } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: ReplayRunData });
if (!submission.value) return json(submission);
const taskRun = await prisma.taskRun.findFirst({
where: { friendlyId: runParam }, // no auth, no tenant scope
include: { runtimeEnvironment: true, project: { include: { organization: true } } },
});
if (!taskRun) return redirectWithErrorMessage(...);
const replayRunService = new ReplayTaskRunService();
const newRun = await replayRunService.call(taskRun, {
environmentId: submission.value.environment, // attacker-controlled
payload: submission.value.payload,
});
This is the classic Remix footgun. In Remix a route’s loader (for GET) and action (for mutations) are independent entry points. Authenticating the loader does nothing for the action — a POST straight at the route skips the loader entirely. The route reads as protected if you glance at the top of the file and see requireUser. It isn’t; that guard is on the wrong door.
The tell is one file over. The sibling resources.taskruns.$runParam.cancel.ts does the same “act on a run by friendlyId” operation, and its action gets it right:
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request); // (1) authenticate
const { runParam } = ParamSchema.parse(params);
const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: { organization: { members: { some: { userId } } } } // (2) scope to caller
},
});
Cancel authenticates and scopes. Replay, its neighbor, does neither.
Finding 2 — the API replay route authenticated but didn’t scope
apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts:
const authenticationResult = await authenticateApiRequest(request);
// ... authenticationResult.environment.id is available here ...
const taskRun = await prisma.taskRun.findUnique({
where: { friendlyId: runParam }, // no runtimeEnvironmentId filter
});
This one does authenticate — you need a valid private API key. But friendlyId is declared @unique globally on the TaskRun model, so findUnique resolves any tenant’s run from any tenant’s key. The caller’s environment id is sitting right there in authenticationResult.environment.id and simply isn’t used in the where clause.
Again the correct pattern is a sibling. api.v1.runs.$runParam.reschedule.ts, and half a dozen other api.v1.runs.* routes, do exactly this:
where: {
friendlyId: runParam,
runtimeEnvironmentId: authenticationResult.environment.id,
}
Reschedule scopes to the caller’s environment. Replay, in the same directory, doesn’t.
The shared downstream had no backstop either
Both routes converge on ReplayTaskRunService.call, in apps/webapp/app/v3/services/replayTaskRun.server.ts:
const authenticatedEnvironment = await findEnvironmentById(
overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId
);
if (!authenticatedEnvironment) return;
// ... proceeds to create a new run in authenticatedEnvironment ...
findEnvironmentById is a bare lookup by id — no project, organization, or membership constraint. So even if you imagine one of the route-level checks getting patched but not the other, the service itself would still happily create a run in whatever environment id it was handed, carrying the original run’s payload and task identifier. There was no defense-in-depth layer that cross-checked “does this destination environment belong to the same project as the run I’m replaying.” That third check is the one that would have contained the blast radius regardless of which route the request came through, and it’s the one the fix ultimately added.

The proof of concept
The setup is what an attacker actually has: they sign up for trigger.dev, which gives them their own organization, project, and a development environment whose id they can read from their own dashboard. The one thing they need from the victim is a run’s friendlyId.
# Finding 1 — no cookies, no API key. The action never checks.
curl -X POST 'https://target.example.com/resources/taskruns/run_VICTIM_FRIENDLY_ID/replay' \
-F 'environment=ATTACKER_OWN_ENV_ID' \
-F 'payload={}' \
-F 'failedRedirect=/'
# Finding 2 — the attacker's own private API key, from their own tenant.
curl -X POST 'https://target.example.com/api/v1/runs/run_VICTIM_FRIENDLY_ID/replay' \
-H 'Authorization: Bearer tr_dev_ATTACKERS_OWN_KEY'
Both create a new TaskRun row with runtimeEnvironmentId set to the attacker’s environment, payload copied from the victim’s run, and taskIdentifier copied from the victim’s run. The attacker then opens that run in their own dashboard and reads the payload. Repeat with different friendlyId values to walk more runs.

One honest caveat that bounds the severity, and which I flagged in the report. friendlyId is a 21-character nanoid — roughly 107 bits of entropy — so you cannot brute-force or internet-scan for victim runs. This is a targeted bug, not a spray. The realistic path to a victim friendlyId is that these ids leak: into webhook bodies, error logs, support tickets, shared dashboard URLs, screenshots. That’s a real vector for a targeted attacker and a non-vector for a mass one, and the writeup should say which it is rather than rounding up.
The failedRedirect form field on Finding 1 also had no same-origin validation, a small open-redirect surface riding along on the same unauthenticated route.
Impact
- Confidentiality. The primary impact: cross-tenant disclosure of task run payloads, one targeted
friendlyIdat a time. Task payloads are exactly the kind of thing that carries customer records, internal identifiers, and occasionally secrets. - Integrity. If the attacker names the victim’s environment as the destination instead of their own, the replay queues a real run on the victim’s workers with an attacker-chosen payload — arbitrary job injection into someone else’s environment, gated on the attacker knowing that environment’s id.
- Availability / billing. Repeated replays into the victim’s environment consume its queue concurrency and billed compute; the run-creation path reports invocation usage against the environment’s organization.
The confidentiality class needs nothing but a friendlyId. The integrity and billing angles additionally need the victim to have a deployed worker for the targeted task, which is why the advisory scores the unauthenticated dashboard route (8.6) above the API route (7.6).
The fix
trigger.dev shipped the fix in 4.5.2 as a bundled security release, PR #4199, merged 2026-07-09. The relevant server changes in that release are the ones named reject-cross-project-replay-environment and query-and-api-endpoint-hardening — which is the right shape: scope the two routes to the caller’s tenant, and add the missing cross-project check in ReplayTaskRunService so the destination environment must belong to the run’s project regardless of how the request arrived. That third check is the one that closes the class rather than the two instances.
If you run self-hosted trigger.dev, the action is simply to be on 4.5.2 or later. There’s no safe configuration toggle to reach for in the meantime the way there was with Vaultwarden — the routes were unconditionally exposed, so the upgrade is the mitigation.
What a five-reporter pileup tells you
Here’s the thing I didn’t expect and think is the most useful part of this one. The advisory credits several independent reporters. This wasn’t a bug one person found and disclosed; it was a small crowd arriving at the same authorization gap in the same short window, the same way ch1nhpd and I converged on the Vaultwarden SSO bug five weeks apart. Wernerina and matte1782 are credited alongside me; I reported the two replay endpoints and the missing downstream cross-check as a chain.

A convergence like that is a measurement, and it measures the same thing the Vaultwarden duplicate did: the gap between “code that exists” and “code that has been read by someone looking specifically for this class.” When several unrelated auditors independently find the same missing-authorization bug in a matter of weeks, the bug was not hiding. It was sitting in the open in a popular multi-tenant product, and the reason it survived until mid-2026 is that the surface — per-route tenant scoping across a large Remix route table — had not been getting first-pass adversarial attention proportional to how load-bearing it is.
That’s the actionable read for the next audit. The targets where independent finders pile up are the targets where the security model is “every route enforces tenancy itself,” the route count is large, and the enforcement is copy-pasted rather than centralized. Those codebases accumulate exactly this bug: one route in a family of twenty forgets the where clause its siblings all have. The method that finds it is boring and mechanical — enumerate the family, tabulate the guard each member performs, investigate the blank cells — which is precisely why a model is good at the enumeration and precisely why you cannot let the model adjudicate the blanks. The table is the model’s job. Deciding whether a blank cell is a real hole or a guard you didn’t see is yours.
I’ll also say the honest thing about being one of several finders, since I said it last time too. The first reaction to seeing other names on the advisory is a small competitive flinch, and it’s the wrong reaction. The right one: a convergence is the cleanest possible evidence that the method generalizes. Several people, plausibly with different tooling, walked into the same gap from the same surface. The bug was there to be found by anyone who thought to tabulate the route family. That the method isn’t mine alone is the point of the method.
Disclosure timeline
- 2026-05-05: I verify and reproduce both findings against self-hosted trigger.dev at HEAD (commit
31999afc), and report the chain — two replay endpoints plus the missing downstream cross-project check. - 2026-06-19: The report is accepted.
- 2026-07-09: trigger.dev 4.5.2 ships. The fix lands in the bundled security release PR #4199, and the advisory GHSA-9fq3-68cw-r7p2 is published, crediting the independent reporters. High severity, no CVE assigned.
- 2026-07-10: This writeup.
The bundled release is its own signal, the same one Vaultwarden’s four-advisory drop gave: a maintainer receiving a cluster of tenant-scoping and hardening reports at once and shipping them together. query-and-api-endpoint-hardening alongside reject-cross-project-replay-environment alongside a batch of access-control and log-redaction changes reads like a quarter’s worth of accumulated external findings against the multi-tenant surface, patched in one pass. That’s healthy behavior, and it confirms what the reporter pileup already implied: this surface had been quietly overdue for a first hostile read, and in June it got several at once.
The full submission writeup and reproducer are in my research archive. Originally published at tomryan.dev.

How this was written
This post was drafted from my notes by an AI model and then edited by me. The reasoning, decisions, and corrections are mine; the prose started from a machine. The underlying technical work this post describes is real.
Licensed CC-BY-4.0.