We recently launched OIDC workload identity on Trigger.dev, which meant building our own issuer.
This post may be useful for engineers building the issuing side: CI systems, job runners and serverless platforms that give their code tokens for other services.
It gives an assortment of things to bear in mind, which might help if you're doing this in the future.
Give each job a way to prove which job it is
Every issuer has to know which job is asking for a token. For example:
- GitHub Actions gives each job a request credential and URL in two environment variables.
- Kubernetes and Vercel skip that step and hand the job its OIDC token directly.
Trigger.dev runs durable AI agents and workflows as tasks. Every time a task runs, including each retry, that attempt gets its own signed run token. When the code calls auth.getOIDCToken(), it sends the run token, and we check which run and attempt it belongs to before signing anything.
Only sign tokens for the attempt that's currently running
That run token is a bootstrap credential: the thing a job uses to ask for its OIDC token. Bootstrap credentials can outlive their job, so ideally they're short-lived and narrowly scoped. We only sign if the run is still executing and the token belongs to its current attempt, so a token from a finished attempt or an earlier retry can't get one. While the attempt is running, its run token still can.
No other credential can request an OIDC token.
Sign run tokens and OIDC tokens with separate keys
Trigger.dev run tokens and OIDC tokens are both JWTs. If one key signed both, a verifier (a service that checks your tokens, like AWS) that skips a claim check could accept one as the other.
So run tokens get their own key set, and we never publish its public keys. A run token can't pass as an OIDC token, and an OIDC token can't call our API.
For OIDC tokens we use RS256 with 2048-bit RSA keys, which every major verifier accepts.
Build the subject from IDs that are never reused
Customers' trust rules decide which tokens to accept by matching the subject (sub). So the subject needs to name something that can't be handed to someone else later.
A GitHub subject looks like repo:octo-org/octo-repo:ref:refs/heads/main. If octo-org is renamed or deleted, someone else can register the name, create octo-repo, and get tokens that match the old trust rules.
The fix is to put IDs in the subject, because IDs are never reused. In April 2026 GitHub announced immutable subject claims, which add the owner and repository IDs to the default subject for new repositories. Ours starts with the organization ID: org:{orgId}:project:{projectRef}:env:{type}:{slug}.
Give each customer organization its own issuer URL
By default, GitHub uses one issuer for all of github.com, so a trust rule that skips sub trusts every repository on GitHub. Vercel added Team Issuer mode in October 2024 to get around this, and we did the same: each Trigger.dev organization gets its own issuer, https://oidc.trigger.dev/<org-slug>.
A customer's AWS identity provider for that URL only matches tokens from that issuer, so if they forget to check sub, only their own organization's tokens can match. Customers' own services should still check iss, aud and sub.
Never change the issuer URL, and match it exactly
Once customers set the issuer URL up as trusted in services like AWS and Google Cloud, it can't change without breaking their setups. Renaming an organization only changes its display name, and we don't reassign a deleted organization's slug.
A path in the issuer URL has one side effect in AWS: condition keys include it, so customers write oidc.trigger.dev/<org-slug>:sub in their trust policy, not oidc.trigger.dev:sub.
Your discovery document's issuer also has to match the iss in your tokens exactly, trailing slash included, because verifiers compare them as plain strings.
Make the subject only as specific as your real boundaries
The subject should name the smallest thing your platform actually keeps apart. Anything finer suggests a separation that isn't there.
For us, that's the environment, like production or staging. An environment's tasks are deployed together as one bundle, much like one app, so a subject per task would suggest isolation the deploy doesn't have.
Finer detail can still go in extra claims. We add run, attempt and task IDs so they show up in customers' logs, and customers who need per-task access can turn on AWS session tags with awsCredentialProvider({ sessionTags: true }), which adds a verified task ID tag their role can check.
If you add claims like these later, make them opt-in. Once an issuer sends session tags, every role that trusts it has to allow sts:TagSession, so turning them on for everyone would break existing setups.
Only show wildcards at the end of the subject
Customers will want to trust groups too, like every Preview environment in a project (...:env:PREVIEW:*). Each branch gets its own Preview environment, so anyone who can push a branch gets a matching token.
In your docs, only show wildcards at the end of the subject, since one in the project segment trusts every project in the organization. Also make sure no segment value can contain your delimiter, or one subject could pass for another.
Only issue tokens to deployed runs
We only issue tokens to deployed runs. Local trigger dev runs happen on machines we don't control. If you have a local mode that gets tokens, give it a subject no production trust rule could match by accident.
Rotate signing keys in stages so cached keys keep working
Verifiers don't fetch your public keys on every request. They cache them, some for as long as your Cache-Control header says and some on their own schedule.
Many also refetch when a token arrives with a kid (key ID) they haven't seen, with a cooldown. For example, createRemoteJWKSet in jose, a popular JavaScript library for checking tokens, caches keys for up to 10 minutes and refetches at most every 30 seconds.
That caching is what makes rotating keys awkward. A verifier still holding your old key list will reject tokens signed with a key it hasn't seen yet. And if an old key disappears while tokens it signed are still valid, those tokens start failing too. So we rotate in stages:
- Publish the new key beside the old one, but don't sign with it yet.
- Wait until every cache could have it.
- Start signing with the new key.
- Keep publishing the old key until its tokens have all expired.
- Remove it.
How long step 2 takes depends on the verifier. For ones that follow our max-age, it's 6.5 minutes: the 5-minute max-age, our servers' 30-second cache and a 60-second margin. Most of the others refetch when they see the new kid, but a few only refresh on their own schedule, and no fixed wait covers those.
Every region or CDN edge also has to serve the new key before anything signs with it.
Step 4 depends on the longest-lived token you could have signed. Our signing code caps lifetimes at 24 hours, so even though the tokens we issue to tasks last 5 minutes, retired keys stay published for just over a day.
The max-age is a tradeoff. A shorter one makes rotation faster but means more fetches.
Conclusion
Most of this is hard to change once customers depend on it, so it's worth planning things out before you ship it. In particular, decide on your issuer URL, subject format and rotation timings before your first customer starts relying on them.
We're on the other side of this too. With trusted publishing, GitHub Actions is the issuer and Trigger.dev accepts its tokens to authorize deploys.
Workload identity is available now on Trigger.dev Cloud. To try it, follow the docs.





