Secure a GraphQL host
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. Read the security disclaimer before shipping any of this.
This guide builds a host that accepts API keys and JWTs, serves a gated mutation, a query any
signed-in caller can run, an operations namespace only operators reach, and records every
request in an audit trail. It is the Auth sample's shape, trimmed; the
sample's E2E suite proves each gate.
The defaults are closed. Until a surface declares who may reach it, the host does not start, and a caller with no credential reaches nothing but what you mark public. The rules are on Authorization rules and the reasoning on Where Trax checks authorization.
Packages: Trax.Effect.Data.Postgres, Trax.Effect.Provider.Json, Trax.Mediator,
Trax.Scheduler, Trax.Api.GraphQL, Trax.Api.Auth.ApiKey, Trax.Api.Auth.Jwt,
Trax.Api.GraphQL.Audit.
1. Write the host
using Trax.Api.Auth;
using Trax.Api.Auth.ApiKey;
using Trax.Api.Auth.Jwt;
using Trax.Api.GraphQL.Audit;
using Trax.Api.GraphQL.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("TraxDatabase")!;
// Authentication. Demo credentials only in Development; real ones from configuration.
if (builder.Environment.IsDevelopment())
{
builder.Services.AddTraxApiKeyAuth(keys => keys
.Add("ops-key-do-not-use-in-production", id: "ops", "Operator")
.Add("billing-key-do-not-use-in-production", id: "billing", "Billing"));
builder.Services.AddTraxJwtAuth(jwt => jwt.UseSymmetricKey(
"my-app-dev", "my-app", "my-app-dev-signing-key-do-not-use-in-production"u8.ToArray()));
}
else
{
var ops = builder.Configuration.GetSection("Auth:OpsKey");
if (ops.Exists())
builder.Services.AddTraxApiKeyAuth(keys => keys.AddHashed(
Convert.FromBase64String(ops["Salt"]!), Convert.FromBase64String(ops["Hash"]!),
id: "ops", "Operator"));
if (builder.Configuration["Auth:Jwt:Authority"] is { Length: > 0 } authority)
builder.Services.AddTraxJwtAuth(authority, builder.Configuration["Auth:Jwt:Audience"]!);
}
builder.Services.AddAuthentication(); // the calls above are conditional,
builder.Services.AddTraxPrincipalAccessor(); // so register what they would bring
// Every policy a gate names. Roles need no registration.
builder.Services.AddAuthorization(o =>
o.AddPolicy("VerifiedEmail", p => p.RequireClaim("email_verified", "true")));
// Trax. AddScheduler backs the operations namespace.
builder.Services.AddTrax(trax => trax
.AddEffects(effects => effects.UsePostgres(connectionString).AddJson())
.AddMediator(typeof(Program).Assembly)
.AddScheduler(scheduler => scheduler));
// GraphQL: operators only on `operations`, each other surface declares its own posture,
// every request audited.
builder.Services.AddTraxGraphQL(graphql => graphql
.ExposeOperationQueries()
.ExposeOperationMutations()
.GateOperations(roles: "Operator")
.AddAudit<AuditLogSink>());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();
app.Run();What each part does, and where it can differ:
- Credentials. A key or signing key containing
do-not-use-in-productionrefuses the host outside Development, so a copied demo credential cannot go live. In production, register API keys as a salt and SHA-256 hash from a secret store withAddHashed, or resolve them yourself withAddTraxApiKeyAuth<TResolver>(); take JWTs from your identity provider withAddTraxJwtAuth(authority, audience). For tokens from several issuers use AddTraxJwtDispatcher; for interactive browser sign-in, AddTraxOidcAuth. EveryAddTrax*Authcall adds its scheme to one combined policy and none becomes the default scheme; Trax tries each registered scheme on a GraphQL request (how). AddAuthentication()andAddTraxPrincipalAccessor(). Needed only because the auth calls are conditional. With neither branch configured, the host still starts and every gated surface refuses every caller.- Policies. Register each policy a
[TraxAuthorize],GateOperationsorRequireAuthorizationnames; an unregistered one refuses the host. - The operations namespace. Exposing it needs a gate.
GateOperations(roles: "Operator")gates it alone and leaves the rest of the endpoint open.AddSchedulerregisters the services it runs on. - Middleware.
UseAuthentication()andUseAuthorization()come beforeUseTraxGraphQL().
2. Declare each surface's posture
Every [TraxQuery], [TraxMutation] or [TraxBroadcast] train and every [TraxQueryModel]
entity declares [TraxAuthorize] (optionally with a policy or roles) or [TraxAllowAnonymous].
A queue-only mutation for the Billing role, whose callers can follow its runs over a subscription,
and a query any signed-in caller can run:
using Trax.Core.Functional;
using Trax.Effect.Attributes;
using Trax.Effect.Services.ServiceTrain;
[TraxAuthorize(Roles = "Billing")]
[TraxMutation(GraphQLOperation.Queue, Namespace = "billing")]
[TraxBroadcast]
public class IssueInvoiceTrain
: ServiceTrain<IssueInvoiceInput, IssueInvoiceOutput>,
IIssueInvoiceTrain
{
protected override Task<Either<Exception, IssueInvoiceOutput>> Junctions() =>
Chain<IssueInvoiceJunction>().Resolve();
}
[TraxAuthorize]
[TraxQuery(Namespace = "billing")]
public class ListInvoicesTrain
: ServiceTrain<ListInvoicesInput, ListInvoicesOutput>,
IListInvoicesTrain
{
protected override Task<Either<Exception, ListInvoicesOutput>> Junctions() =>
Chain<ListInvoicesJunction>().Resolve();
}A Billing key queues dispatch { billing { issueInvoice(...) } } and follows the run with
onTrainStateChanged. Any authenticated caller runs discover { billing { listInvoices { ... } } }.
Only an Operator key reaches operations. Without [TraxBroadcast], a Billing caller who is not
an operator would have nothing to receive and is refused when subscribing.
A [TraxQueryModel] entity declares its posture the same way, on its class, and so does every
entity it reaches through a navigation; its context goes on the builder with
.AddDbContext<T>(). [TraxAuthorize] decides who may read the type, not which rows: to scope the
rows a caller sees, add a query filter that reads the caller (see
Owner-scoped contexts).
The rules for each kind of surface are on Authorization rules.
3. Write the audit sink
The audit pipeline hands entries to your sink in batches, off the request thread:
using Trax.Api.GraphQL.Audit;
public sealed class AuditLogSink(ILogger<AuditLogSink> logger) : ITraxAuditSink
{
public Task WriteAsync(IReadOnlyList<TraxAuditEntry> batch, CancellationToken ct)
{
foreach (var entry in batch)
logger.LogInformation(
"{Principal} {Operation} ok={Success} {Error}",
entry.PrincipalId,
entry.OperationName,
entry.Success,
entry.ErrorText
);
return Task.CompletedTask;
}
}A production sink writes to a store of its own; the Auth sample's writes each entry to a table.
What an entry records
An entry records who called what and whether it was allowed, with the document's literals blanked, no variables, and error codes rather than messages. Refused requests are recorded too. The fields are on TraxAuditEntry, the batching, retry and truncation settings on TraxAuditOptions.
4. Start the host
Run it. If a surface has no posture, a policy is not registered, the operations namespace has no gate, or a demo credential is registered outside Development, the host stops with a message naming what to change. Startup refusals lists every one.
Then call it without a credential:
curl -s http://localhost:5000/trax/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ discover { billing { listInvoices { invoices { id } } } } }"}'The answer is a GraphQL error with code TRAX_AUTHORIZATION and the message "Not authorized.".
Every refusal looks like that: it does not say which train, policy or role was involved, and an
unknown train name returns TRAX_TRAIN_NOT_FOUND without echoing it. Detail goes to the server log.
Error codes lists every code. With
-H 'X-Api-Key: billing-key-do-not-use-in-production' the query answers.
Gate the whole endpoint instead
When nothing on the endpoint is public, gate every operation and drop the per-surface requirement:
using Trax.Api.GraphQL.Extensions;
services.AddTraxGraphQL(graphql => graphql.RequireAuthorization());The gate applies to every operation, over HTTP and over a subscription socket, and a refused
request is audited. The IDE page and the schema download are not behind it; they follow
AllowIntrospection, which is off outside Development. With no argument the gate is the combined
Trax policy, so any registered scheme is enough; pass ApiKeyDefaults.PolicyName or another
policy to require one. [TraxAllowAnonymous] on any exposed surface then refuses the host, since
nothing can be reached anonymously.
Gate through the builder, not only with an ASP.NET Core convention on the mapped route: the startup checks count the builder's gate and do not see the convention.
Authenticate subscriptions from a browser
A browser cannot put a header on a WebSocket upgrade, so an API-key or JWT client sends its
credential in the connection_init payload:
const ws = new WebSocket("wss://host/trax/graphql", "graphql-transport-ws");
ws.onopen = () => ws.send(JSON.stringify({
type: "connection_init",
payload: { authToken: "<api key or jwt>" },
}));AddTraxGraphQL registers the interceptor that reads it, for every scheme registered on the host,
in whatever order. An OIDC cookie needs nothing: the browser sends it on the upgrade. A connection
is closed when its token expires, its sign-in expires, or a periodic re-check finds its API key
revoked.
A browser on another origin is accepted only from an origin the host allows. The default is the origins your CORS default policy names; to set them explicitly:
using Trax.Api.GraphQL.Extensions;
services.AddTraxGraphQL(graphql => graphql
.ExposeOperationQueries()
.GateOperations(roles: "Operator")
.AllowSocketOrigins("https://app.example.com", "https://admin.example.com"));Subscriptions has the payload keys, the routing between schemes, connection lifetime and how to supply your own interceptor.
Record variables in the audit
Variables are not recorded unless you register a redactor that returns them. Remove anything that can carry a secret, at any depth:
using System.Text.Json.Nodes;
using Trax.Api.GraphQL.Audit;
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;
}
}
}using Trax.Api.GraphQL.Audit;
services.AddSingleton<ITraxAuditRedactor, SensitiveFieldRedactor>();A removal list misses the field you did not think of. Where that matters, keep a list of fields to record instead. See ITraxAuditRedactor.
Tighten the limits
AddTraxGraphQL caps query depth, cost, operations per request and per socket, and socket
lifetime, turns introspection off outside Development and serves GraphQL over POST only. Change
any of them on the builder:
using Trax.Api.GraphQL.Extensions;
services.AddTraxGraphQL(graphql => graphql
.ExposeOperationQueries()
.GateOperations(roles: "Operator")
.MaxExecutionDepth(8)
.MaxOperationsPerRequest(25)
.MaxOperationsPerConnection(20)
.MaxConnectionLifetime(TimeSpan.FromMinutes(30))
.AllowIntrospection(ctx => ctx.User.IsInRole("Developer")));A predicate passed to AllowIntrospection decides in every environment, Development included, so
make it check the caller. The defaults and every builder method are on
AddTraxGraphQL. How many runs one principal
may have in flight, how large an input may be, and how long an OnQueue hook may hold its enqueue
are mediator settings: Dispatch limits.
Before production
Trax does none of these for you:
- Serve all GraphQL traffic over HTTPS, and never accept credentials over plain HTTP.
- Read API keys, JWT secrets and database credentials from a secret store. Never commit them.
- Rotate keys on a schedule and on any suspected exposure; a resolver can refuse a revoked key at once.
- Rate-limit with ASP.NET Core's middleware keyed on
trax:principal-id. The id is scheme-qualified (TraxApiKey:alice,TraxJwt:alice), so one person signed in two ways has two buckets. - Alert on a non-zero
trax.audit.dropped(in process,TraxAuditChannel.TotalDropped). A dropped entry is an operation nobody can see. - Protect the dashboard, if the host serves one, with its own gate; see Add the dashboard to a host.
See also
- Authorization rules
- Where Trax checks authorization
- Startup refusals
- Injecting TraxPrincipal: reading the caller in a junction, including one the scheduler also runs.
SDK Reference
AddTraxApiKeyAuth | AddTraxJwtAuth | AddTraxJwtDispatcher | AddTraxOidcAuth | TraxPrincipal | AddTraxGraphQL | AddAudit | TraxAuditEntry | ITraxAuditSink | TraxAuditOptions