Skip to content

The user behind an event in Inngest functions

A run has no request behind it: no HttpContext, no cookie, no signed-in user. When a click starts a run, the function often still needs to know who clicked: to attribute the work, to call another service as that person, or to check what they may see. The Inngest.UserContext packages carry the user along. When your app sends an event, they attach selected claims of the current user to it, protected so that nobody can read or change them. In the run, middleware restores the claims as ICurrentUser and as a scoped ClaimsPrincipal.

The restored user is a snapshot from the time the event was sent, not a fresh authorization. A run can start later and last for days. The user may have lost a role, been blocked or been deleted since. Use the restored user to know who caused the work, and check what that person may do against current data for anything that matters.

There are two packages:

Package What it gives you
Inngest.UserContext The format, the protector, the middleware, ICurrentUser and AddUserContext. It needs no ASP.NET Core, so a worker that only restores users, such as a Connect worker, uses this one alone.
Inngest.UserContext.AspNetCore AddUserContextFromHttpContext, which takes the user from HttpContext.User. For the app that sends events while it serves requests.
Terminal window
dotnet add package Inngest.UserContext
dotnet add package Inngest.UserContext.AspNetCore

The user is protected with ASP.NET Core Data Protection, which you set up yourself, because the apps that send and restore users have to share its key ring. The web app that sends events:

Program.cs
using Inngest;
using Microsoft.AspNetCore.DataProtection;
builder.Services
.AddDataProtection()
.SetApplicationName("shop")
.PersistKeysToFileSystem(new DirectoryInfo("/var/shop/keys"));
builder.Services
.AddInngest(options => options.AppId = "shop-web")
.AddUserContext(options => options.Claims = ["sub", "name", "email", "role"])
.AddUserContextFromHttpContext();

Claims is the list of claim types that travel. Everything else in the principal stays behind. The worker that runs the functions needs the same key ring and application name, and only AddUserContext:

Program.cs of the worker
using Inngest;
using Microsoft.AspNetCore.DataProtection;
builder.Services
.AddDataProtection()
.SetApplicationName("shop")
.PersistKeysToFileSystem(new DirectoryInfo("/var/shop/keys"));
builder.Services
.AddInngest(options => options.AppId = "shop-worker")
.AddUserContext()
.AddConnect();

An app that sends events and serves them, as a single ASP.NET Core app often does, uses AddUserContext and AddUserContextFromHttpContext once. Register AddUserContext before middleware that needs the user, because middleware runs in registration order.

Take ICurrentUser where you need the user:

ExportReport.cs
using Inngest;
public sealed record ReportRequested(string ReportId) : IInngestEventData
{
public static string EventName => "shop/report.requested";
}
public interface IReportAccess
{
Task<bool> MayExportAsync(string userId, string reportId, CancellationToken cancellationToken);
Task<string> ExportAsync(string userId, string reportId, CancellationToken cancellationToken);
}
[InngestFunction("export-report")]
public sealed class ExportReport(ICurrentUser user, IReportAccess access) : InngestFunction<ReportRequested>
{
public override async Task<object?> ExecuteAsync(
FunctionContext<ReportRequested> context, CancellationToken cancellationToken)
{
if (!user.IsAuthenticated)
{
throw new NonRetriableException("A report is exported for the user who asked for it.");
}
var userId = user.Principal.FindFirst("sub")!.Value;
var reportId = context.Event.Data.ReportId;
// The snapshot says who asked. Whether they may still have the report is a question for today's data.
var may = await context.Step.RunAsync("check-access", ct => access.MayExportAsync(userId, reportId, ct));
if (!may)
{
throw new NonRetriableException($"User {userId} may no longer export report {reportId}.");
}
return await context.Step.RunAsync("export", ct => access.ExportAsync(userId, reportId, ct));
}
}

ICurrentUser has three members:

Member Meaning
Principal The restored ClaimsPrincipal: one identity with the authentication type Inngest.UserContext and the claims the sender allowed, as strings. The name and role claim types of the original identity are kept, so Identity.Name and IsInRole answer as they did in the request. Claim value types, issuers and properties are not carried. Without a user it is anonymous: no claims, and Identity.IsAuthenticated is false.
IsAuthenticated True when the event carried a user that is valid for this run.
IssuedAt When the sender took the claims, or null without a user. Use it to treat an old snapshot with more care.

A run has no user when the event came from somewhere that has none: a cron trigger, another service, a webhook, the Inngest dashboard, or a sender that is not signed in. Inngest Cloud documents that event.user is empty when it replays a function (see its send reference), so a replayed run has no user there. The open-source dev server reloads the user of the event instead, so development and production can differ. A replay or a rerun is a new run with an id of its own, and it is judged by the time it was created, so a payload that has expired by then is not accepted whichever server keeps it.

ICurrentUser always answers for the run whose code is running. It is a singleton, and it is the same in every scope of the run: the scope of the call, a scope your code creates in a step, a singleton’s. Outside a run it is nobody. The user is restored when the function starts, and the function class is created before that, so a service must read the user when it uses it and not in its constructor. For the same reason OnRunStartAsync and the other hooks that run before the function starts see nobody, and so does the code of a middleware registered before AddUserContext ahead of its next(). A task that the function starts keeps the user of its run for as long as the call lasts; once the call has answered, the task sees nobody. ClaimsPrincipal is also registered, as a scoped service, for code that wants one: it is the principal as it is when the service is created, which is anonymous before the function starts. An app that registered its own ClaimsPrincipal keeps it.

The restoring app has to unprotect what the sending app protected. Data Protection makes that possible with three things, and its defaults give none of them to two separate deployments:

  1. The same keys. Persist the key ring to a store both apps reach: a shared folder, Azure Blob Storage, Redis or Entity Framework Core (see key storage providers). The default is a folder in the user profile, or memory in a container, and then each app makes keys of its own.
  2. The same application name. SetApplicationName("shop") in both apps. Without it Data Protection isolates apps by their content root path, and payloads do not cross.
  3. The same purpose, which the package sets. Payloads of another feature that use the same keys, such as a cookie, are not accepted as users.

The keys are secrets: whoever holds them can make a payload for any user. Encrypt them at rest with ProtectKeysWith... and limit who can read the store. A key lives 90 days by default. Data Protection creates the next one two days before, and an app reads the store at least daily, so apps have a new key before they use it (both are fixed in Data Protection). Expired keys still unprotect, so payloads made with them are read for as long as the key stays in the ring. A revoked key does not: revoking one with IKeyManager.RevokeKey is the way to stop trusting payloads made with a key that leaked, and running apps notice when they refresh their cached key ring.

Set the ring up before the apps that share it start for the first time. Apps that both begin with an empty store each create a key, and may not read each other’s payloads until their next refresh of the store.

When the restoring app cannot unprotect a payload, the run has no user, and the log says why (see when the user cannot be used).

The user field of an event is where Inngest documents user data, and it stores it encrypted at rest. The packages add one key to it and leave all other keys to you:

"user": {
"external_id": "u-42",
"_inngest_dotnet_user": { "v": 1, "t": "CfDJ8..." }
}

v is the format version. t is the protected text of this JSON, which only apps with the key ring can read:

{ "v": 1, "iat": 1793268000, "exp": 1793872800, "ev": "shop/report.requested", "nt": "name", "rt": "role",
"c": { "sub": ["u-42"], "name": ["Ada Lovelace"], "role": ["admin", "support"] } }

iat is when the claims were taken and exp when they stop being accepted for new runs. ev is the name of the event the text was made for: it is accepted on that event only, so a payload that leaked from one event cannot be attached to another to run as the user. An invocation is an event of one name for any function, and Inngest builds it from a body that its caller supplies, so the text of an invocation names the function too, in fn, as <app id>-<function id>. nt and rt are the name and role claim types when they are not the .NET defaults, and c holds the claims. Data Protection signs and encrypts the text, so what Inngest’s dashboard, the logs of whatever reads events and every other function triggered by the event can see is ciphertext.

Think of each claim you allow as personal data in an event. List what the runs need and nothing else: Claims is matched exactly against the claim types of the principal (the default allows sub and ClaimTypes.NameIdentifier), and whatever it does not list is never read. The JSON of the payload may be up to MaxSize bytes, 4096 by default. A user over the bound is not attached: the event is sent without it and a warning is logged. The protected text is about a third larger than the JSON, and an event can be 256 KiB on Inngest’s Free plan and 3 MiB on Pro and Business (see its usage limits).

An event that should not carry the user, such as an audit event for another team, is left out with EventFilter. It runs for every event that is sent:

inngest.AddUserContext(options =>
{
options.Claims = ["sub", "name"];
options.EventFilter = @event => !@event.Name.StartsWith("audit/", StringComparison.Ordinal);
});

Lifetime is seven days by default. It is not how long a run may use the user. A function runs again from its top on every call, so a user that expired halfway through a run would change under it: a check at the top of the function would pass before a long sleep and fail after it. So the lifetime is judged once, by when the run was created and when its event asks it to start. A run id is a ULID, and its first part is the creation time; Inngest makes it, so the sender of an event cannot choose it.

  • A run created before the expiry, for an event that does not ask to start after it, keeps its user for as long as it lives. It can sleep, wait and retry for as long as Inngest lets a run last, and every call sees the same user.
  • A run created after the expiry has no user. This is what bounds a payload that leaked from an event and is sent again.
  • A run that Inngest created in time and then holds back, behind other runs in a queue say, keeps its user. An event that asks to start later does not. Inngest creates its run when it receives the event but starts it at the event’s timestamp, which whoever sends the event chooses, so the run is judged by the later of the two times. An event scheduled further ahead than the lifetime carries no user. Time that passes before the run exists counts too, such as a debounce period.
  • A run id that is not a ULID, such as the made-up ids of a unit test, is judged by the clock at each call, which can change the answer while the run goes on. A warning says so once for the run.
  • The events and invocations a run sends carry the same expiry, not a new one. A chain of runs therefore cannot go on for longer than the lifetime of the first send: a run created after it has no user.

Choose a lifetime that covers the longest time between sending an event and the time it asks its run to start, and for a chain of runs, between the first send and the last event of the chain. A longer lifetime lets a leaked payload start runs for longer.

Events that a run sends carry the run’s user, as they carry its sessions. This covers context.Step.SendEventAsync and an IInngestClient used inside the function. The source of the user is never asked there: the request that called the app belongs to Inngest, not to a user. Propagate = false turns it off, and EventFilter picks events. Each event gets a payload made for its own name. A user that will have expired by the time the event’s run starts is not passed on: a run that sleeps past the expiry sends its events and invocations without a user, and writes an information line that names the event, not the user.

Invocations do not pass through middleware, so the user goes to an invoked function when the code asks for it with WithUser(context), with the function’s FunctionContext. The invoked function restores it like any other:

var options = new InvokeOptions("render-report") { Data = context.Event.Data }
.WithUser(context);
var rendered = await context.Step.InvokeAsync<string>("render", options);

Other keys that InvokeOptions.User already holds are kept. A run without a user passes none.

A failure handler gets the user of the run that failed. Inngest sends it the failed run’s event, with its user, and the user is judged as that run was. Other functions that listen for inngest/function.failed do not get it.

ImportCatalog.cs
using Inngest;
public sealed record CatalogUploaded(string CatalogId) : IInngestEventData
{
public static string EventName => "shop/catalog.uploaded";
}
public interface IAlerts
{
Task TellAsync(string userId, string message, CancellationToken cancellationToken);
}
[InngestFunction("import-catalog")]
public sealed class ImportCatalog(ICurrentUser user, IAlerts alerts) : InngestFunction<CatalogUploaded>, IFailureHandler
{
public override Task<object?> ExecuteAsync(
FunctionContext<CatalogUploaded> context, CancellationToken cancellationToken) =>
throw new NonRetriableException("The catalog is not valid.");
public async Task OnFailureAsync(FunctionContext<FunctionFailure> context, CancellationToken cancellationToken)
{
if (user.IsAuthenticated)
{
await alerts.TellAsync(user.Principal.FindFirst("sub")!.Value, "Your catalog could not be imported.", cancellationToken);
}
}
}

There is no single user where there is no single event. A function that batches events has none, and neither has the failure handler of one. The events that WaitForEventAsync matched are results of steps, and their user is not restored.

An event without the reserved key is not an error: the run has no user. A payload that is there and cannot be used is, and OnInvalid decides what the run does:

Setting A run whose payload cannot be used
Anonymous (the default) Writes one warning and runs without a user.
FailRun Fails. An expired payload, one made for another event or function and one with an unknown format fail without retries; one that cannot be unprotected is retried, because the app may not have the sender’s newest key yet, and fails on the last attempt. A failure handler never fails on its user: it takes the Anonymous path, so that it can do what it is there for.
Reason in the log What it means
it has expired The run was created after the expiry.
it was made for another event or function The event name, or for an invocation the invoked function, is not the one the payload was made for.
it could not be unprotected It was changed, or made with a key or purpose this app does not have. The app’s key ring or application name usually differs from the sender’s. A key store that cannot be read is not this: Data Protection reports it as an exception, the call fails whatever OnInvalid says, and Inngest retries it, because running without the user would give the same run different users on different calls.
its format is not known to this version The format version is newer than this package. Deploy the restoring apps before the senders when you upgrade.
it is not valid The payload was protected but is not a user.

The warning is written once for the run and names the event and the reason. No log line, exception message or trace of these packages contains a claim or the protected text, and yours should not either.

Anyone with your event key can send an event with any user. What they can do with it:

  • Make up a user: nothing. Text that the key ring did not produce does not unprotect, the run has no user, and a warning is logged. They cannot choose claims.
  • Move a payload to another event: nothing. A payload is bound to the name of its event, and one made for an invocation to the function that is invoked.
  • Send a payload they copied again, for example from the dashboard: the run starts as that user until the payload expires, with any data they like. A far timestamp does not stretch it, because a run is judged by the later of its creation and the event’s timestamp. Keep Lifetime as short as your delays allow, and keep the event key secret.
  • Read the claims: nothing, without the keys.

What the payload protects is the claims, the times, the name of the event and, for an invocation, the function. Nothing else in the event is part of it: not the data, the id, the timestamp or the other keys of user, which anyone who can send the event can set. So a restored user says who the sender was and not that the rest of the event is theirs: a function that acts for a user checks the data against the user, such as an order’s owner against sub, and makes the work idempotent.

Whoever holds the key ring can make payloads for any user, so treat it like a signing key.

AddUserContextFromHttpContext is a source of the user for sends outside a run. For another kind of host, register an IUserContextSource. This one takes the user of a Blazor circuit:

AuthenticationStateSource.cs
using System.Security.Claims;
using Inngest;
using Microsoft.AspNetCore.Components.Authorization;
public sealed class AuthenticationStateSource(AuthenticationStateProvider provider) : IUserContextSource
{
public async ValueTask<ClaimsPrincipal?> GetUserAsync(CancellationToken cancellationToken) =>
(await provider.GetAuthenticationStateAsync()).User;
}
inngest.AddUserContext();
inngest.Services.AddSingleton<IUserContextSource, AuthenticationStateSource>();

It is asked for every send outside a run, and only an authenticated user is attached.

To protect users some other way, such as with a key from a vault, register an IUserContextProtector as a singleton. It turns the JSON of the claims into text and back, and the packages do the rest: the format, the expiry, the binding to the event. It has to detect changes and should encrypt, because the text is visible to anyone who can see the event. Its TryUnprotect returns false for text that is not valid and throws for failures that are not the text’s fault, so that Inngest retries the call. When one is registered, Data Protection is not used and need not be set up.

Functions that take ICurrentUser are tested with a fake in the services:

SignedInAs.cs
using System.Security.Claims;
using Inngest;
public sealed class SignedInAs(string userId) : ICurrentUser
{
public ClaimsPrincipal Principal { get; } = new(new ClaimsIdentity([new Claim("sub", userId)], "Test"));
public bool IsAuthenticated => true;
public DateTimeOffset? IssuedAt => null;
}

Register it with services.AddSingleton<ICurrentUser>(new SignedInAs("u-42")) in the services of the test host. WithUser(context) takes the user from the run and not from ICurrentUser, so a fake alone passes none to an invocation, and without AddUserContext it throws. To test how the user travels, register AddUserContext with Data Protection in the test services and give the event a user: the host restores it, and records the events a function sends, so SentEvents shows the payload they carry.

MassTransit and NServiceBus carry the user in a message header that a filter or behavior sets when a message is sent and reads when it is consumed, and Hangfire jobs take the user id as an argument or a job parameter, because a job has no request. These are plain values that the broker is trusted to keep. Inngest accepts an event from anyone who has the event key, and the user is stored in the event, so these packages protect it and bind it to the event, and judge it by the time the run was created. The TypeScript SDK has no equivalent, and other SDKs see an opaque _inngest_dotnet_user key in user.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.