API Security

NO WARRANTY FOR SECURITY. Trax.Api.Auth and Trax.Api.GraphQL.Audit are provided AS-IS. Trax, its authors, and contributors are NOT LIABLE for any security breach, credential leak, data loss, or damage arising from systems built on top of these packages. Securing your deployment is the SOLE RESPONSIBILITY OF THE CONSUMER.

This page covers authentication, audit logging, and operational hygiene for Trax GraphQL hosts. Read the security disclaimer before shipping any of this to production.

Trax ships three pluggable authentication schemes, all feeding the same TraxPrincipal abstraction:

  • Trax.Api.Auth.ApiKey - header-based API keys for service-to-service calls.
  • Trax.Api.Auth.Jwt - JWT bearer tokens for SPA and machine-to-machine API clients.
  • Trax.Api.Auth.Oidc - OpenID Connect code flow for interactive browser sign-in.

Multiple schemes can coexist in a single host. Every AddTrax*Auth call contributes its scheme to the combined TraxAuthClaimTypes.TraxAuthPolicy, so endpoints protected by that policy accept credentials from any registered scheme.

API-Key Authentication

Trax.Api.Auth.ApiKey wraps an ASP.NET Core AuthenticationHandler. Every request that presents a configured header (default X-Api-Key) is resolved by an ITraxPrincipalResolver<string> into a TraxPrincipal. Missing header returns NoResult, letting [AllowAnonymous] routes keep working. Present but unresolved returns Fail.

Static key set (the common case):

using Trax.Api.Auth.ApiKey;
 
if (builder.Environment.IsDevelopment())
{
    services.AddTraxApiKeyAuth(keys => keys
        .Add("admin-key-do-not-use-in-production",  id: "admin",  "Admin", "Player")
        .Add("player-key-do-not-use-in-production", id: "player", "Player"));
}

Cleartext keys in source are demo keys. A key containing do-not-use-in-production makes the host refuse to start outside Development, so a copied demo key cannot go live by accident; production keys come from a secret manager (below).

Keys registered through the builder are salted and SHA-256 hashed at startup, then compared with CryptographicOperations.FixedTimeEquals on every request. The resolver iterates every entry without short-circuiting, so lookup cost is independent of which (if any) entry matches. Cleartext comparison is not reachable from consumer code.

Resolver class (for keys backed by a database, cache, or HTTP service):

services.AddTraxApiKeyAuth<MyApiKeyResolver>();

The handler runs the resolver on every request that presents credentials, so cache if your resolver hits I/O.

Pre-Hashed Keys (Production)

Production hosts should load salt and hash bytes from a secret manager so cleartext never enters the process:

var admin = builder.Configuration.GetSection("ApiKeys:Admin");
 
services.AddTraxApiKeyAuth(keys => keys.AddHashed(
    salt:   Convert.FromBase64String(admin["Salt"]!),
    sha256: Convert.FromBase64String(admin["Hash"]!),
    id:     "admin",
    "Admin"));

For a principal that needs a display name distinct from its id, or custom claims, both Add and AddHashed have Func<TraxPrincipal> overloads.

Header Handling

The handler rejects:

  • Requests with multiple X-Api-Key headers (AuthenticateResult.Fail).
  • Requests with a single X-Api-Key value containing a comma (reverse proxies sometimes coalesce duplicate headers per RFC 7230 ยง3.2.2; those are ambiguous and never forwarded to the resolver).

Missing or whitespace-only headers return AuthenticateResult.NoResult() so [AllowAnonymous] routes keep working.

JWT Bearer Authentication

Trax.Api.Auth.Jwt is a thin wrapper around Microsoft.AspNetCore.Authentication.JwtBearer. Token validation (signature, issuer, audience, lifetime) runs through Microsoft's handler. After validation, Trax runs an ITraxPrincipalResolver<JwtTokenInput> to project the validated token into a TraxPrincipal.

For tokens from an OIDC provider (Auth0, Okta, Entra, Cognito):

services.AddTraxJwtAuth(jwt => jwt.UseAuthority(
    authority: "https://login.example.com",
    audience:  "my-api"));

For self-issued tokens:

services.AddTraxJwtAuth(jwt => jwt.UseSymmetricKey(
    issuer:   "https://trax.internal",
    audience: "my-api",
    key:      Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)));

The default resolver maps sub (or nameidentifier) to TraxPrincipal.Id, name or preferred_username to DisplayName, and role / roles / ClaimTypes.Role claims to Roles. Other claims pass through on the custom claim bag. For custom mapping (database enrichment, token revocation checks), supply ITraxPrincipalResolver<JwtTokenInput> via AddTraxJwtAuth<TResolver>.

OpenID Connect Authentication

Trax.Api.Auth.Oidc wires the browser-facing OIDC code flow with PKCE. A challenge against OidcDefaults.SchemeName redirects to the identity provider; on callback, the handler validates the id-token and signs the user into a session cookie (OidcDefaults.CookieSchemeName). Subsequent requests authenticate against the cookie.

services.AddTraxOidcAuth(oidc => oidc
    .UseAuthority("https://login.example.com", "my-client-id")
    .WithClientSecret(builder.Configuration["Oidc:ClientSecret"]!)
    .AddScope("email"));
 
app.MapGet("/login", () =>
    Results.Challenge(new AuthenticationProperties { RedirectUri = "/" },
        new[] { OidcDefaults.SchemeName }));

Unauthenticated API requests against the cookie scheme return 401 (not a redirect to /Account/Login). For non-browser clients that present bearer tokens, use AddTraxJwtAuth instead; OIDC is for the interactive sign-in path.

Subscription Authentication

Browsers cannot attach custom headers to a WebSocket upgrade, so header-bound schemes (API key, JWT bearer) carry credentials in the connection_init payload instead. Cookie-bound schemes (OIDC) need no special handling: the browser attaches cookies to the upgrade request, so the cookie middleware authenticates the upgrade like any HTTP request.

AddTraxGraphQL registers one socket interceptor, TraxCompositeSocketInterceptor, for every host. It reads which schemes are registered from the finished container on the first connection, so authentication may be registered before or after AddTraxGraphQL, and it hands each connection to the API-key or JWT strategy by the credential the payload carries. See Subscriptions for the routing rules. Supplying your own interceptor through ConfigureSchema replaces it.

services.AddTraxApiKeyAuth(...);   // or AddTraxJwtAuth / AddTraxJwtDispatcher
services.AddTraxGraphQL(...);      // reads what is registered above it

API key

const ws = new WebSocket("wss://host/trax/graphql", "graphql-transport-ws");
ws.onopen = () => ws.send(JSON.stringify({
    type: "connection_init",
    payload: { authToken: "admin-key-do-not-use-in-production" }  // or "apiKey"
}));

When AddTraxApiKeyAuth is registered, API-key connections go to TraxApiKeySocketInterceptor. It resolves the token via the same ITraxPrincipalResolver<string> used by the REST handler, attaches the resulting principal to HttpContext.User for the socket lifetime, and rejects the connection when the token is missing or invalid.

JWT bearer

const ws = new WebSocket("wss://host/trax/graphql", "graphql-transport-ws");
ws.onopen = () => ws.send(JSON.stringify({
    type: "connection_init",
    payload: { authToken: "<jwt>" }  // or "bearer"
}));

When AddTraxJwtAuth is registered, JWT connections go to TraxJwtSocketInterceptor. It validates the token against the same JwtBearerOptions (signature, issuer, audience, lifetime, clock skew) the HTTP handler uses - the WS and HTTP paths cannot diverge. This includes Authority/JWKS schemes (Cognito, Google, any OIDC provider): the interceptor fetches signing keys from the scheme's discovery document when the options carry no static key. It then runs ITraxPrincipalResolver<JwtTokenInput> and attaches the resulting principal.

No extra code required. The browser attaches the session cookie (trax.oidc) to the WebSocket upgrade request; ASP.NET Core's cookie middleware reads and validates it on the upgrade, and HttpContext.User is populated for the socket lifetime. This is the one genuinely symmetric path across HTTP and WS.

Allowed origins

A WebSocket upgrade that carries an Origin header is accepted only from origins the host serves: the endpoint's own host, or an allowed origin. Anything else is answered with 403 before the handshake. An upgrade with no Origin header, which is what non-browser clients send, is accepted. The allowed origins default to those of your CORS default policy (AddCors(o => o.AddDefaultPolicy(...)), including AllowAnyOrigin()); set them explicitly with AllowSocketOrigins(...) on the GraphQL builder, which replaces the CORS default:

services.AddTraxGraphQL(graphql => graphql
    .AddDbContext<AppDbContext>()
    .AllowSocketOrigins("https://app.example.com", "https://admin.example.com"));

The endpoint's own host is compared without its scheme, so an https page served through a TLS-terminating proxy is recognised. The check applies wherever the Trax schema serves a socket, whether UseTraxGraphQL() maps it or you map it yourself with MapGraphQL(path, "trax"), and it does not apply to another HotChocolate schema on the same host. A host whose browser clients are on another origin and whose CORS policy is a named one, not the default, needs AllowSocketOrigins(...).

Multiple JWT issuers

AddTraxJwtDispatcher routes subscription tokens by their iss claim, the same way it routes HTTP requests. When a dispatcher is registered, JWT connections go to TraxJwtDispatcherSocketInterceptor instead of the single-scheme JWT strategy, so each connection validates against the scheme its issuer maps to. Unmapped issuers are rejected.

Custom interceptor

To authenticate against a scheme Trax does not model, supply your own ISocketSessionInterceptor through ConfigureSchema:

services.AddTraxGraphQL(graphql => graphql
    .AddDbContext<AppDbContext>()
    .ConfigureSchema(b => b.AddSocketSessionInterceptor<MySocketInterceptor>()));

This replaces Trax's interceptor and is independent of auth-registration order. Derive from DefaultSocketSessionInterceptor and read the credential from the connection_init payload in OnConnectAsync.

Per-Train Authorization

TraxPrincipalExtensions.ToClaimsPrincipal populates both trax:principal-id (the resolver's id qualified by the scheme, {scheme}:{id}, so two issuers' subjects never collide; see Qualified Principal Ids) and ClaimTypes.Name, so the existing [TraxAuthorize] machinery from Authorization works unchanged. Policies and roles behave exactly as ASP.NET Core documents them. Role comparison is exact and case-sensitive, as IsInRole makes it.

Error Messages are Generic

Trax deliberately never leaks which train a request tried to reach, which policy failed, or which role was required. A denied request returns a GraphQL error with code TRAX_AUTHORIZATION and the message "Not authorized." - nothing more. A request for an unknown train name returns TRAX_TRAIN_NOT_FOUND with a generic message; the requested name never echoes back. Diagnostic detail lives only in server-side logs.

Accessing the Current User

Junctions and application services that need the authenticated user inject TraxPrincipal directly:

public class SendMessageJunction(TraxPrincipal user) : Junction<SendMessageInput, SentMessage>
{
    public override Task<SentMessage> Run(SendMessageInput input) =>
        service.SendAsync(user.Id, user.DisplayName, input.Body);
}

No IHttpContextAccessor plumbing. Every Trax auth scheme registers a scoped TraxPrincipal factory that resolves the current request's principal. Gate the upstream endpoint with [TraxAuthorize] and the junction can trust that the principal is present. See Injecting TraxPrincipal for dual-path (API + scheduler) patterns.

GraphQL Hardening Defaults

AddTraxGraphQL installs resource-exhaustion guards by default. All are tunable via the builder.

services.AddTraxGraphQL(graphql => graphql
    .MaxExecutionDepth(8)                   // default: 15
    .MaxOperationsPerRequest(25)            // default: 50
    .MaxOperationsPerConnection(20)         // default: 100
    .AllowIntrospection(ctx => IsInternalIp(ctx))   // default: Development only
    .ConfigureCost(opts => opts.MaxFieldCost = 2000)); // default: 1000
GuardDefaultPurpose
MaxExecutionDepth15Rejects nested queries deeper than this (introspection fields excluded).
MaxFieldCost1000HotChocolate cost-analyzer ceiling. Prevents expensive field combinations.
DefaultResolverCost10Base cost applied to each resolver in the cost analyzer.
IntrospectionOn in Development, off elsewherePrevents anonymous schema enumeration in production. Decided per request on every transport (HTTP POST, GET, multipart, WebSocket). A predicate passed to AllowIntrospection replaces the default in every environment, Development included, and receives the request's HttpContext, so it can check the caller. The schema download (?sdl, /schema, /schema.graphql) and the GraphQL IDE follow the same answer and return 404 when it is no; an allowed download is sent Cache-Control: private.
MaxOperationsPerRequest50Caps the operations one request invokes: root fields, and the fields under each namespace (dispatch, discover, operations, nested namespaces such as operations { deadLetters }, and a train's declared Namespace). The namespace field itself is not counted, so dispatch { a: refund(...) b: refund(...) } counts two. Aliases and batched operations count, selections inside fragment spreads and inline fragments count as if written in place, and selections sharing a response path count once. Rejects with TRAX_TOO_MANY_OPERATIONS during validation, before any resolver runs.
MaxOperationsPerConnection100Caps the operations one WebSocket connection runs at once. An operation started past it gets TRAX_SOCKET_OPERATION_LIMIT and takes no place; the connection stays open, and a place frees when one of its operations completes. It is per connection, so it does not bound how many connections a client opens.
operations namespaceOff (queries and mutations)The predefined operations.* queries (manifests, executions, dead letters, health, hosts, config) and mutations (trigger, cancel, requeue) are not exposed unless the consumer opts in via ExposeOperationQueries() / ExposeOperationMutations(). The mutation surface drives ITraxScheduler directly, so leaving it open lets any caller disrupt scheduled work, and the read surface discloses internal hostnames and per-instance execution counts. Exposing either without a gate fails at startup: answer with GateOperations(policy, roles) to gate the namespace alone, RequireAuthorization() to gate the whole endpoint, or AllowAnonymousOperations() to acknowledge a deliberately public control plane. The gate is the only check on manifest triggers and dead-letter requeues; queueTrain and requeueExecution also apply the train's [TraxAuthorize] requirements (see The Operations Surface). Train inputs (an execution's input, a manifest's properties, a work queue entry's input) and effect settings, any of which can hold credentials, answer to the same gate and are served one row at a time by the detail reads, never by a list (see Train inputs and the operations gate).
HTTP GETOffGraphQL runs over POST only. A cross-site top-level navigation carries a SameSite=Lax cookie, so a GET-executable query could run a [TraxQuery] train as the signed-in user. AllowGetRequests() opts in; an opted-in GET still needs the GraphQL-preflight header and runs queries only. See Serving GraphQL over GET.

Serving GraphQL over GET

GraphQL over HTTP GET is off. A browser attaches a SameSite=Lax cookie to a cross-site top-level navigation, so with GET on, a link on another site could run a [TraxQuery] train as the signed-in user. The response is not readable cross-site, but the train still runs.

A host with a client that needs GET, such as a CDN caching persisted queries by id, opts in:

services.AddTraxGraphQL(graphql => graphql.AllowGetRequests());

Two guards stay on. A GET must carry the GraphQL-preflight: 1 header, which a navigation cannot add, and only queries run over GET; a mutation is refused. The setting belongs to the trax schema, so it holds for UseTraxGraphQL() and for a directly mapped MapGraphQL(path, "trax") alike. The IDE page and the SDL download are separate HotChocolate options and are unaffected.

Gating GraphQL Execution Without Locking the IDE

Endpoint-level RequireAuthorization blanket-gates the route, including the GET that serves the GraphQL IDE. Developers can't even load the IDE to paste a key. The builder method splits these concerns:

services.AddTraxGraphQL(graphql => graphql.RequireAuthorization());

The policy is checked in HotChocolate's request pipeline, so it applies only when the request is an actual GraphQL operation. The IDE's HTML shell, the schema download and CORS preflights are not affected by it; the IDE and the schema download follow AllowIntrospection instead, so outside Development they are served only when your predicate allows the request. POST queries and mutations are checked against the policy and rejected with a GraphQL error, with status 400:

{ "errors": [{ "message": "Not authorized.", "extensions": { "code": "TRAX_AUTHORIZATION" } }] }

The error renders inline in the IDE result pane and matches the shape of per-train [TraxAuthorize] failures. The refusal happens inside execution, so the audit pipeline records it as an unsuccessful entry.

The default policy is the combined TraxAuthClaimTypes.TraxAuthPolicy, which every AddTrax*Auth extension contributes its scheme to. When multiple schemes are registered (API key + JWT, etc.), any one of them is sufficient. To require a specific scheme:

services.AddTraxGraphQL(graphql => graphql.RequireAuthorization(ApiKeyDefaults.PolicyName));

Subscription auth is a separate path: the connection_init payload is checked by TraxCompositeSocketInterceptor, which AddTraxGraphQL registers. RequireAuthorization only governs HTTP execution. If the policy isn't actually registered at startup, the host fails fast with a message naming the policy and pointing to AddTraxApiKeyAuth.

Per-Principal Concurrency

PerPrincipalMaxConcurrentRun(int) on TraxMediatorBuilder caps the number of concurrent RunAsync executions for any single authenticated principal. Requests that exceed the cap queue on a per-principal semaphore until in-flight work completes.

services.AddTrax(trax => trax
    .AddEffects(effects => effects.UsePostgres(connStr))
    .AddMediator(mediator => mediator
        .ScanAssemblies(typeof(Program).Assembly)
        .PerPrincipalMaxConcurrentRun(10)));

The cap bucket key is the trax:principal-id claim. Anonymous callers (no authenticated user) are not subject to the cap because they have no stable identity to bucket against. Scheduler and remote-worker calls bypass the cap entirely (no HttpContext).

Input Size Cap

WithMaxInputJsonBytes(int) on TraxMediatorBuilder caps the UTF-8 byte length of caller-supplied train input JSON. Default is 256 KiB. Oversize inputs are rejected with TrainInputValidationException (code TRAX_INVALID_INPUT) after authorization runs but before deserialization, so attacker-controlled JSON never reaches the deserializer.

Input is read without JSON reference handling: $id, $ref and $values are not honoured, so the parsed input is the tree the caller sent. An enqueue also caps the input as it is stored on the work queue entry (indented, every member written) at 4 times MaxInputJsonBytes, and refuses a larger one with the same exception before the entry is written. Writing that form stops the moment it crosses the cap, so an oversized one is never built in full. See TrainInputReader.

OnQueue Time Limit

WithMaxQueueHookDuration(TimeSpan) on TraxMediatorBuilder bounds how long a train's OnQueue hook may hold its enqueue's pooled connection and open transaction. Default is 30 seconds. Past it the enqueue fails with QueueHookTimeoutException and releases the connection, so hooks that wait on something slow cannot drain the pool for every other enqueue. See OnQueue.

Audit Pipeline

Trax.Api.GraphQL.Audit is a HotChocolate ExecutionDiagnosticEventListener that captures each request, serializes it into a TraxAuditEntry, and enqueues to a bounded channel. A background writer drains the channel in batches and hands them to your ITraxAuditSink. The request thread never blocks on the sink. A request the endpoint policy refuses is captured too, as an unsuccessful entry with the caller's principal, or <anonymous> when it had no credential.

Wiring:

services.AddTraxGraphQL(graphql =>
    graphql.AddAudit<MyPostgresAuditSink>(opts =>
    {
        opts.ChannelCapacity = 10_000;
        opts.BatchSize = 50;
        opts.FlushInterval = TimeSpan.FromMilliseconds(500);
    })
);
OptionDefaultPurpose
ChannelCapacity10,000Bounded channel size. Drops increment trax.audit.dropped.
BatchSize50Max entries per sink invocation.
FlushInterval500msMax wait before flushing a partial batch.
MaxDocumentLength65,536Documents longer than this are cut, marked ...[truncated], and followed by [selected fields: ...], every Type.field the operation selects. Padding ahead of the fields that matter cannot push them out of the record.
SkipIntrospectiontrueDrop introspection operations (every top-level selection is __schema, __type or __typename) from the log.
SkipSubscriptionstrueSubscriptions don't fit the request/response model.
DefaultPrincipalId<anonymous>Used when the request has no Trax principal.
MaxRetries3Sink retry attempts before dropping a batch. Dropped entries increment trax.audit.dropped.
RetryBackoff100msInitial backoff, doubles on each retry.

What an Entry Records

An audit store is kept for a long time and read by more people than the API, so an entry records what was called and by whom, not the values the caller sent:

  • The document has its literals replaced. Every string becomes "" and every number 0, wherever it appears: inline arguments, nested input objects, list items, directive arguments and variable defaults. Field names, aliases, input field names, booleans, enum values and null are kept. login(input: { user: "bob", password: "hunter2" }) is recorded as login(input: { user: "", password: "" }). This is the same transform Apollo uses for its operation signatures, except that input objects and lists keep their shape.
  • Variables are not recorded unless you register an ITraxAuditRedactor that returns them. The default, DefaultAuditRedactor, returns null.

To record variables, register a redactor and keep only what is safe. It receives the variables as a JsonObject, with input objects as nested objects and lists as arrays, so it can remove a field at any depth:

public sealed class SensitiveFieldRedactor : ITraxAuditRedactor
{
    private static readonly HashSet<string> Sensitive = new(StringComparer.OrdinalIgnoreCase)
    {
        "password", "token", "apiKey", "secret",
    };
 
    public JsonObject? Redact(JsonObject? variables)
    {
        Strip(variables);
        return variables;
    }
 
    private static void Strip(JsonNode? node)
    {
        switch (node)
        {
            case JsonObject obj:
                foreach (var key in obj.Select(p => p.Key).Where(Sensitive.Contains).ToList())
                    obj.Remove(key);
                foreach (var (_, child) in obj)
                    Strip(child);
                break;
            case JsonArray array:
                foreach (var child in array)
                    Strip(child);
                break;
        }
    }
}
 
services.AddSingleton<ITraxAuditRedactor, SensitiveFieldRedactor>();

A removal list fails open for a field you did not think of. Where that matters, keep a list of fields to record instead. ErrorText is not redacted: a resolver that puts an input value in its error message puts it in the audit.

Drops and Shutdown

Every entry the listener offers is either handed to the sink or counted in trax.audit.dropped (and TraxAuditChannel.TotalDropped). An entry is dropped when the channel is full, when the sink refuses its batch after MaxRetries retries, or when shutdown runs out of time.

On graceful shutdown the writer stops accepting entries and writes every entry it already accepted. It has until the host's shutdown timeout (HostOptions.ShutdownTimeout, 30 seconds by default); the cancellation token the sink receives fires only then. What is still unwritten at that point, including a batch a sink is stuck on, is counted as dropped, and the host finishes stopping.

Operational Hygiene

Trax does none of these for you:

  • Transport: serve all GraphQL traffic over HTTPS. Never accept credentials over plaintext HTTP.
  • Key storage: read API keys, JWT secrets, and DB credentials from a secret manager. Never commit them.
  • Rotation: rotate keys on a schedule and on any suspected exposure. Invalidate in the resolver.
  • Rate limiting: use ASP.NET Core's rate-limit middleware keyed on trax:principal-id.
  • Introspection: off outside Development by default. If you open it with AllowIntrospection, make the predicate check the caller rather than returning true.
  • Audit dashboards: alert on non-zero trax.audit.dropped. A dropped entry is an invisible operation.
  • Redaction: variables are not recorded by default. If you register an ITraxAuditRedactor to record them, remove every field that could carry a token, PII, or a secret, at any depth.

SDK Reference

AddTraxApiKeyAuth | AddTraxJwtAuth | AddTraxJwtDispatcher | AddTraxOidcAuth | TraxPrincipal | Injecting TraxPrincipal | ITraxPrincipalResolver | AddTraxGraphQL | AddAudit | TraxAuditEntry | ITraxAuditSink | TraxAuditOptions