Mert Özen Further With Every Line
Backend gRPC and GraphQL — #09

Operating GraphQL Safely: Cost Analysis, Persisted Operations, Authorization and Schema Evolution

Mert Özen Nov 14, 2026 13 min 5 views
Operating GraphQL Safely: Cost Analysis, Persisted Operations, Authorization and Schema Evolution

To operate GraphQL safely in production you stop unknown queries with persisted operations and expensive ones with cost analysis and a depth limit, enforce authorization at field and resource level, and check schema changes with a diff in CI. With Hot Chocolate 16 we show how aliases and variable batching multiply cost, semantic introspection, error masking, @requiresOptIn and cache control failing to start under a Turkish region setting, all with real output.

Version note: The code in this post was built and run with .NET 10, Hot Chocolate 16.7, Entity Framework Core (EF Core) 10.0 and SQL Server 2025; the schema comparison was done with GraphQL Inspector 7.0.

Guess First

In the previous post we made books DataLoader-backed and paged: 50 books and their authors come back with two SQL queries, and the page size is capped at 50. A client sends the same field with 20 different aliases in one request, each with first: 50:

{
  a0: books(first: 50) { nodes { title author { name } } }
  a1: books(first: 50) { nodes { title author { name } } }
  # ... up to a19
}

The page limit isn't exceeded, the depth is three levels, and the DataLoader caches the authors. Does Hot Chocolate 16 run this request with its default settings? If it does, how many book rows get serialized?

The answer: it doesn't run it. But the answer to "why" is hidden in a number, and knowing how to read that number is the first step in operating GraphQL in production.

Resource Consumption: Cost, Depth, Aliases and Batching

In a REST (Representational State Transfer) API, the cost of an endpoint is roughly fixed. In GraphQL the cost is set by the query the client writes: depth (author → books → author → ...), breadth (large pages), multiplying the same field with aliases, and sending several operations in one HTTP request. These are the GraphQL-specific forms of the risks in the API Security series' API4 (Unrestricted Resource Consumption) post.

Since Hot Chocolate 14, cost analysis based on IBM's GraphQL Cost Directive specification has been on and enforced by default. Every field gets a weight; the @cost(weight: "10") and @listSize(assumedSize: 50, ...) directives you saw in the previous post's SDL are the inputs of this calculation. The GraphQL-Cost: validate header returns a query's cost without executing it:

{ books(first: 5) { nodes { title author { name } } } }
{"extensions":{"operationCost":{"fieldCost":61,"typeCost":12}}}

{ books(first: 50) { nodes { title author { name } } } }
{"extensions":{"operationCost":{"fieldCost":511,"typeCost":102}}}

{ books(first: 50) { nodes { author { books { author { books { title } } } } } } }
{"extensions":{"operationCost":{"fieldCost":2011,"typeCost":252}}}

The cost grows linearly with the page size: 61 for 5 books, 511 for 50. Because first is a slicing argument, Hot Chocolate knows the list size from the number the client gives. The default limits are 1,000 for field cost and 10,000 for type cost. The third query and the 20-alias request from the start were rejected:

400 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"The maximum allowed field cost was exceeded.","extensions":{"code":"HC0047","fieldCost":10220,"maxFieldCost":1000}}]}

20 × 511 = 10,220. Aliases multiply the cost, and cost analysis adds them up one by one. This rejection happens before execution, at validation; not a single query reached the database.

Hot Chocolate 16 adds another multiplier: variable batching. Sending the same operation with several variable sets in one request is on by default, and the response streams as JSON (JavaScript Object Notation) Lines, one object per line. At first glance each set seems to be measured separately; sending two first: 10 sets with GraphQL-Cost: validate returned a separate "fieldCost":111 on each line. But the limit applies to the total: 3 sets with first: 30 (3 × 311 = 933) ran, 4 sets were rejected with "fieldCost":1244. Sending several different operations as an array (request batching) is off by default in Hot Chocolate 16; when I tried it I got HC0009 "Invalid GraphQL Request." If you need to turn it on, be sure to lower MaxBatchSize (default 1024).

Next to cost analysis I added two cheap rules:

builder.Services
    .AddGraphQLServer()
    // ...
    .AddMaxExecutionDepthRule(6)
    .ModifyRequestOptions(o =>
    {
        o.ExecutionTimeout = TimeSpan.FromSeconds(10);
        // ...
    });
{"errors":[{"message":"The GraphQL document has an execution depth of 7 which exceeds the max allowed execution depth of 6.","locations":[{"line":1,"column":1}],"extensions":{"allowedExecutionDepth":6,"detectedExecutionDepth":7}}]}

The depth limit cuts off pathological nested queries that cost might miss, in a crude but easy-to-understand way. The execution timeout defaults to 30 seconds and covers time spent waiting in the queue as well. Even 10 seconds is generous for a web screen.

Persisted (Trusted) Operations

Cost analysis answers "how expensive is it"; persisted operations answer a more basic question: "do we know this query at all?". Bookstore's only client is our own Angular application. Every query that app can send is known at build time; there's no reason for the production server to accept any other query. In this setup the client sends not the query text but its hash, and the server looks the hash up in its own store.

builder.Services
    .AddGraphQLServer()
    // ...
    .ModifyRequestOptions(o =>
    {
        // ...
        o.PersistedOperations.OnlyAllowPersistedDocuments = !builder.Environment.IsDevelopment();
    })
    .UsePersistedOperationPipeline()
    .AddFileSystemOperationDocumentStorage("./persisted");

The HotChocolate.PersistedOperations.FileSystem package looks up each operation in a persisted/<hash>.graphql file; the default hash is MD5 in hex. I wrote the book list query (query BookList($first: Int!)) to a file and computed the MD5 hash of its contents: b5923b0f7b178c314b18da2432c9799c. In Production:

{"id":"b5923b0f7b178c314b18da2432c9799c","variables":{"first":2}}
200 {"data":{"books":{"nodes":[{"title":"Distributed Systems Vol. 1","price":155.00,"author":{"name":"Ada Byron"}},{"title":"Protobuf Vol. 1","price":160.00,"author":{"name":"Alan Turing"}}]}}}

{"query":"{ books(first: 2) { nodes { title } } }"}
400 {"errors":[{"message":"Only persisted operations are allowed.","extensions":{"code":"HC0067"}}]}

{"id":"00000000000000000000000000000000"}
400 {"errors":[{"message":"The specified persisted operation key is invalid.","extensions":{"code":"HC0020","requestedKey":"00000000000000000000000000000000"}}]}

This one setting shrinks the attack surface of the alias multiplication and depth attacks above to almost nothing: the client can only call queries you wrote. Persisted operations are still validated; when I stored a deep query as a persisted operation, the depth rule rejected it too. So don't remove the limits; let the two work together.

Producing the files by hand doesn't scale. The right place is the client's build: GraphQL Code Generator's client preset can extract every operation into a manifest (hash → query), and the deploy pipeline writes that manifest into the server's store (file system, Redis or Azure Blob Storage). We set this up in the next post on Angular. The hash algorithm must match the client's; in Hot Chocolate you can switch to SHA-256 (Secure Hash Algorithm 256) with AddSha256DocumentHashProvider().

Introspection and Semantic Introspection

One of Hot Chocolate 16's new features is semantic introspection: natural-language search over the schema with __search. It's designed to help LLM agents find the right field. I tried it in Development:

{ __search(query: "order price") { coordinate score } }

{"data":{"__search":[{"coordinate":"Book.price","score":1},{"coordinate":"Book.costPrice","score":0.8798894882202148},{"coordinate":"OrderItem.unitPrice","score":0.8798894882202148},{"coordinate":"Query.order","score":0.6310880184173584}, ...]}}

The second result stands out: Book.costPrice. As we'll see shortly, this field is open to staff only, but its existence shows up in the search results. Authorization protects the data, not the shape of the schema. In the Production environment the same request and a classic __type query were rejected even with the persisted-only setting turned off:

{"errors":[{"message":"Introspection is not allowed for the current request.","locations":[{"line":1,"column":3}],"extensions":{"code":"HC0046","field":"__search"}}]}

Both are off by default in Production, while __typename keeps working, which clients need to tell union members apart. The short version of the decision: keep introspection off in a public API and give developers the schema through an SDL file or a schema registry. For an internal API, opening it to a specific role is reasonable; Hot Chocolate lets you allow it per request with AllowIntrospection() in a request interceptor.

Authorization: Fields, Policies and Resource-Based Checks

The HotChocolate.AspNetCore.Authorization package connects ASP.NET Core's authentication and authorization infrastructure to GraphQL. JWT validation and the Staff policy are the same setup as in the ASP.NET Core Web API series; on the GraphQL side you add .AddAuthorization() to the builder. There are three levels:

[QueryType]
public static partial class OrderQueries
{
    [Authorize]
    public static async Task<List<Order>> GetMyOrdersAsync(
        ClaimsPrincipal user,
        BookstoreContext db,
        CancellationToken cancellationToken)
    {
        string email = user.FindFirstValue("email")!;
        return await db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.Id)
            .ToListAsync(cancellationToken);
    }

    [Authorize]
    public static async Task<Order?> GetOrderAsync(
        int id,
        ClaimsPrincipal user,
        BookstoreContext db,
        CancellationToken cancellationToken)
    {
        Order? order = await db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, cancellationToken);

        // resource-based check: staff see every order, customers only their own
        bool allowed = order is not null
            && (user.IsInRole("staff") || order.CustomerEmail == user.FindFirstValue("email"));
        return allowed ? order : null;
    }
}

[ObjectType<Book>]
public static partial class BookNode
{
    static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.CostPrice).Type<DecimalType>().Authorize("Staff");
        descriptor.Ignore(b => b.SupplierNote);
    }
}

The [Authorize] here comes from the HotChocolate.Authorization namespace. When I imported Microsoft.AspNetCore.Authorization out of habit, Hot Chocolate 16's analyzer stopped the build: error HC0106: Use HotChocolate.Authorization.AuthorizeAttribute instead. When I silenced the analyzer with #pragma warning disable HC0106 and tried it, a request without a token reached the resolver directly instead of getting AUTH_NOT_AUTHENTICATED: Hot Chocolate doesn't apply Microsoft's attribute, so the field is left unprotected. Deniz and Ali have one order each (1 and 2); the same query with three identities:

{ order(id: 1) { id customerEmail total } myOrders { id total } }

# no token
{"errors":[{"message":"The current user is not authorized to access this resource.","path":["myOrders"],"extensions":{"code":"AUTH_NOT_AUTHENTICATED"}},{"message":"The current user is not authorized to access this resource.","path":["order"],"extensions":{"code":"AUTH_NOT_AUTHENTICATED"}}],"data":null}

# ali@example.com
{"data":{"order":null,"myOrders":[{"id":2,"total":160.00}]}}

# mert@example.com, role=staff
{"data":{"order":{"id":1,"customerEmail":"deniz@example.com","total":160.00},"myOrders":[]}}

Ali couldn't see Deniz's order number 1 even though he passed [Authorize]; the resolver did the check. The attribute answers "is the user signed in", not "is this resource theirs". The rule we set up for REST in the API Security series' API1 BOLA post is the same in GraphQL: the ownership check belongs next to the data access. Returning the same response (null) whether the resource doesn't exist or belongs to someone else also prevents discovering existing orders by guessing ids.

The first version of the field-level costPrice was Decimal!. When Ali asked for costPrice in the book list, the response was "data":{"books":{"nodes":null}}: the unauthorized field turned into null, and because it was non-null, the null took the whole list with it. After making it nullable with Type<DecimalType>(), the books came back, with only costPrice null and an AUTH_NOT_AUTHORIZED error. Every field protected by authorization should be nullable.

Error Masking and Tracing

I added an available field that fetches stock for books from the Inventory service over HTTP, and left Inventory's address unreachable. Because I had turned on IncludeExceptionDetails in Development, the response was:

{"errors":[{"message":"Unexpected Execution Error","path":["books","nodes",0,"available"],"extensions":{"traceId":"52bdb5e35ade3f31661463f4bc3b2c33","exception":{"message":"... (inventory.bookstore.internal:5299)","stackTrace":"   at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, ..."}}}], ...}

The internal host name, the port and the stack trace are on the client. In production this gives an attacker a map of the internal network. In Hot Chocolate the default for IncludeExceptionDetails is Debugger.IsAttached, so it's off unless a debugger is attached. I tied it to the environment and wrote an error filter that sends the details to the log and only a trace id to the client:

public sealed class LoggingErrorFilter(ILoggerFactory loggerFactory) : IErrorFilter
{
    private readonly ILogger _logger = loggerFactory.CreateLogger<LoggingErrorFilter>();

    public IError OnError(IError error)
    {
        if (error.Exception is null)
        {
            return error;
        }

        // details go to the log, the client only gets an id for tracing
        string traceId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
        _logger.LogError(error.Exception, "GraphQL error at {Path} (traceId {TraceId})", error.Path, traceId);
        return error.SetExtension("traceId", traceId);
    }
}
.AddApplicationService<ILoggerFactory>()
.AddErrorFilter<LoggingErrorFilter>()

In the first attempt the filter's constructor took ILogger<LoggingErrorFilter> and the application didn't start: Unable to resolve service for type 'Microsoft.Extensions.Logging.ILogger`1[...LoggingErrorFilter]'. Hot Chocolate 16 separated schema services from application services; services used by error filters, interceptors and diagnostic listeners have to be brought over to the schema side with AddApplicationService<T>(). The response and the log in Production:

{"errors":[{"message":"Unexpected Execution Error","path":["books","nodes",0,"available"],"extensions":{"traceId":"fab675a6b54c80dcb3cce46f16b5be12"}}],"data":{"books":{"nodes":[{"title":"Distributed Systems Vol. 1","available":null}]}}}

fail: Bookstore.Catalog.Types.LoggingErrorFilter[0]
      GraphQL error at books.nodes[0].available (traceId fab675a6b54c80dcb3cce46f16b5be12)
      System.Net.Http.HttpRequestException: ... (inventory.bookstore.internal:5299)

The client can give the trace id to the support team, and the details stay with us.

Schema Evolution: @deprecated, @requiresOptIn and a Diff in CI

Versioning in GraphQL is traditional, but it's done in the schema itself, not with /v2 in the URL: a new field is added, the old one is marked @deprecated, and it's removed once its usage drops to zero. In Bookstore we decided to stop showing the exact stock count to everyone; a status enum takes its place. There's also a recommendations field that is still experimental:

[ObjectType<Book>]
public static partial class BookEvolution
{
    static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.Stock).Deprecated("Use stockStatus. Exact counts are no longer public.");
    }

    public static StockStatus GetStockStatus([Parent(requires: nameof(Book.Stock))] Book book) =>
        book.Stock switch
        {
            0 => StockStatus.OutOfStock,
            < 5 => StockStatus.Low,
            _ => StockStatus.InStock
        };

    [RequiresOptIn("recommendations")]
    public static async Task<List<Book>> GetRecommendationsAsync(
        [Parent(requires: nameof(Book.AuthorId))] Book book,
        IBooksByAuthorIdDataLoader booksByAuthorId,
        CancellationToken cancellationToken) =>
        [.. (await booksByAuthorId.LoadAsync(book.AuthorId, cancellationToken) ?? []).Where(b => b.Id != book.Id).Take(3)];
}
.ModifyOptions(o => o.EnableOptInFeatures = true)
.OptInFeatureStability("recommendations", "experimental")

[RequiresOptIn] is Hot Chocolate 16's feature based on the @requiresOptIn proposal in the GraphQL working group. The field doesn't show up in normal introspection; recommendations was missing from the __type(name: "Book") { fields { name } } list and appeared with fields(includeOptIn: ["recommendations"]). __schema { optInFeatureStability } returned {"feature":"recommendations","stability":"experimental"}. An important detail: opt-in is not access control but a visibility and contract marker. Querying recommendations directly without opting in also returned three books. It's the standard way to tell clients "this field may change, use it at your own risk".

To dump the schema to a file, I added the HotChocolate.AspNetCore.CommandLine package and replaced app.Run() with await app.RunWithGraphQLCommandsAsync(args). Now dotnet run -- schema export --output schema.graphql works. I compared the schema before and after the change with GraphQL Inspector:

npx graphql-inspector diff v1.graphql v2.graphql

Detected the following changes (7) between schemas:

⚠  Directive optInFeatureStability was added to schema
✔  Directive optInFeatureStability was added
✔  Directive requiresOptIn was added
✔  Field recommendations was added to object type Book
✔  Field Book.stock is deprecated
✔  Field stockStatus was added to object type Book
✔  Type StockStatus was added
[success] No breaking changes detected

Then I deliberately made a breaking change and removed Book.isbn:

npx graphql-inspector diff v2.graphql v3.graphql

Detected the following changes (1) between schemas:

✖  Field isbn was removed from object type Book
[error] Detected 1 breaking change

The command's exit code is 1, which is enough to stop a PR in a CI pipeline. The flow: the schema file from main lives in the repo, every PR regenerates the schema, and the build breaks if the diff finds a breaking change. If removal is really needed: first @deprecated, then measure usage (from logs of operation names and field usage), and only then remove.

Getting HTTP Caching Back: GET and Persisted Operations

In the series' GraphQL fundamentals post we said HTTP caching is lost because of the single POST endpoint. Persisted operations bring it back: the query is now a hash, and the URL is short and stable. With the HotChocolate.Caching package you define the cache duration at field level:

[UseConnection(IncludeTotalCount = true)]
[CacheControl(60)]
public static async Task<PageConnection<Book>> GetBooksAsync(...)
.AddCacheControl()
.ModifyCacheControlOptions(o => { o.DefaultMaxAge = 30; o.ApplyDefaults = true; })
.UseQueryCache();

app.MapGraphQLPersistedOperations();
GET /graphql/persisted/b5923b0f7b178c314b18da2432c9799c?variables={"first":2}
HTTP/1.1 200 OK
Cache-Control: max-age=30
Vary: Accept

The response's max-age is the smallest of all fields in the query. Even though books says 60 seconds, fields with resolvers like Book.author get the default duration. On the first attempt, without setting DefaultMaxAge, the header was Cache-Control: max-age=0, meaning not cacheable at all. That's the safest default: better not to cache at all than to put a user-specific field into a public cache by mistake. Never hand queries containing authorization-bound fields like costPrice to a CDN as public.

While writing this section I also ran into a problem I didn't expect. When I added AddCacheControl() and ran the application, it didn't start:

HotChocolate.SchemaException: For more details look at the `Errors` property.
1. `PUBLİC` is not a valid GraphQL name.
 (Parameter 'value') (HotChocolate.Caching.CacheControlScopeType)

A dotted capital İ. When Hot Chocolate 16.7.5 derives GraphQL names from C# enum values (Public → PUBLIC), it upper-cases them in a culture-sensitive way; when the operating system's region is Turkish, the lowercase i becomes a dotted İ and the result isn't a valid GraphQL name. Here the victim was HotChocolate.Caching's cache scope enum; in the next post the Shipped value of our own OrderStatus enum failed with SHİPPED for the same reason. Every enum value containing a lowercase i is at risk. The same application started with an English region setting and failed with Turkish (tr-TR); since .NET takes its default culture from the operating system's region setting, the same happens on a Windows machine with a Turkish region. My fix was to pin the application's culture:

// Hot Chocolate 16.7.5 upper-cases enum names culture-sensitively; under tr-TR, Public becomes "PUBLİC"
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var builder = WebApplication.CreateBuilder(args);

For an API the invariant culture is a sensible default anyway. You don't want a JSON response whose number and date formats change with the server's region settings.

Production Notes

Build the limits in layers and keep them all together: persisted operations stop unknown queries, cost analysis stops expensive ones, the depth rule stops pathological nesting, and the execution timeout stops whatever still runs long. HTTP-level measures like rate limiting sit in front of these, as we described in the API Security series.

Tune the cost limits to real traffic. Aggregate the graphql.operation.fieldCost attribute from the previous post's OpenTelemetry span by operation name, and set the limit a little above the most expensive legitimate query.

Make operation names mandatory and log them. Both schema evolution decisions (who still uses this deprecated field?) and security reviews depend on them.

Test the application on a machine with a Turkish region setting too. Culture-sensitive case conversion is still a source of bugs in the .NET ecosystem; a bug that blows up at startup like the one above is the best case, while one that silently produces wrong data is worse.

Common Pitfalls

Limiting only the page size. Aliases and variable batching multiply the page limit; cost analysis that measures the request as a whole is a must.

Leaving authorization-protected fields non-null. A single unauthorized field turns the whole list into null.

Mistaking [Authorize] for an ownership check. Any signed-in user can ask for someone else's order by id; the resolver or the data access must do the check.

Using Microsoft.AspNetCore.Authorization.AuthorizeAttribute. Hot Chocolate doesn't apply it; 16's analyzer catches it with a compile error, but silencing the error leaves the field unprotected.

Leaving IncludeExceptionDetails on in production. Exception messages carry internal host names, SQL and file paths.

Mistaking @requiresOptIn for authorization. It only changes introspection visibility; anyone can call the field.

A Small Experiment

★☆☆ Get the cost of books(first: 20) and books(first: 40) with the GraphQL-Cost: validate header, and work out where the number 511 comes from using the @cost and @listSize directives.

★★☆ Make persisted operations mandatory in Development too, but allow ad-hoc queries only for tokens with the staff role instead of an X-Developer header. Hint: DefaultHttpRequestInterceptor and AllowNonPersistedOperation().

★★★ Find the operations that use Book.stock: count field usage by operation name from Hot Chocolate's diagnostic events or OpenTelemetry spans, and use that data to decide when you can safely remove the deprecated field.

The server side is now safe and observable. Next is the client: in the next post we connect to this schema from Angular with Apollo Angular, generate typed operations and a persisted operation manifest with GraphQL Code Generator, follow order status live with a subscription and load reviews later with @defer.