Hiding Internal Fields from External Consumers
“Hide this field” is one requirement with four different correct answers in a federated graph, and picking the wrong one produces either a leak or a maintenance burden that never ends. This page gives a concrete rule for choosing between a contract filter, @inaccessible, an authorization directive, and simply not putting the field in the graph. It sits under Contracts and Schema Variants for API Consumers.
When to use this pattern
- Use it whenever a field exists for internal reasons — an operational flag, a cost, a risk score, a migration marker — and some consumer of the graph must never see it.
- Use it before a public launch, as a deliberate audit: every field in the supergraph is either intentionally public or intentionally not, with a mechanism recorded for each.
- Skip it for data that varies by user rather than by audience. A field one signed-in user may read and another may not is authorization, and modelling it as hiding produces a schema that lies to somebody.
Prerequisites
Four mechanisms, four different guarantees
The four options differ along two axes: when the decision is made, and what the field’s absence actually means. Getting these straight is what turns an open-ended debate into a two-minute decision.
Not in the graph at all. The strongest option and the most under-used. If a value exists only to serve one internal job, it may not belong in the schema; a service-to-service call or an internal endpoint keeps it out of the composed graph entirely. Nothing to filter, nothing to leak, nothing to review later.
@inaccessible. The field is in the subgraph, participates in composition, and is absent from every API schema. Use it when the router or another subgraph genuinely needs the field — a key component, a @requires input — but no client should ever query it.
Contract filtering with @tag. The field exists in some API schemas and not others. Use it when the answer differs by audience: internal clients get it, partners do not.
Authorization directives. The field exists in the schema and is denied per request. Use it when the answer differs by caller.
Implementation walkthrough
The example below shows all four in one type, which is unusual in production but makes the distinctions concrete. Each field is annotated with why it uses the mechanism it does.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@tag", "@inaccessible", "@requiresScopes"])
type Order @key(fields: "id") @tag(name: "public") @tag(name: "partner") {
id: ID! @tag(name: "public") @tag(name: "partner")
status: OrderStatus! @tag(name: "public") @tag(name: "partner")
# AUDIENCE difference: partners see the fulfilment centre, the public API does not.
fulfilmentCentre: String! @tag(name: "partner")
# NO AUDIENCE should query this, but the pricing subgraph @requires it,
# so it must exist in the composed graph.
costBasis: Money! @inaccessible
# CALLER difference: the buyer sees their own note, support staff see any.
# This cannot be a tag, because the answer depends on who is asking.
internalNote: String @requiresScopes(scopes: [["orders:support"]])
}
# NOT IN THE GRAPH: `riskModelVersion` is read by the fraud service over its
# own internal API. It is not a field on Order at all, because no GraphQL
# client — internal or external — has a reason to select it.
The decision procedure is short enough to run in a code review. Ask whether any GraphQL client needs it; if not, it does not belong in the schema. Ask whether the router or another subgraph needs it; if yes and no client does, mark it @inaccessible. Ask whether the answer differs by audience; if yes, tag it. Ask whether it differs by caller; if yes, guard it. Exactly one of those four is true for any given field, and answering them in order stops the common failure of tagging something that should never have existed publicly in the first place.
Verification steps
Auditing what actually escapes is a two-command job, and it is worth doing on a schedule rather than only at launch:
# What each audience can see, straight from the registry.
rover graph fetch my-graph@current > internal.graphql
rover graph fetch my-graph@public > public.graphql
# Fields present internally and absent publicly — the intended difference.
comm -23 <(grep -o '^\s*[a-zA-Z]*:' internal.graphql | sort -u) \
<(grep -o '^\s*[a-zA-Z]*:' public.graphql | sort -u)
Then verify the guarantees themselves rather than the configuration. An @inaccessible field should be absent from every variant, so introspecting any router for it must return nothing. A tagged field should be present in one router and absent from another. An authorized field should be present in both and denied for a caller without the scope — a response containing an error with the field still in the schema, not a validation failure.
Finally, confirm the subgraph itself is unreachable from outside. @inaccessible is a supergraph-level guarantee; if a curious partner can reach the subgraph’s own endpoint, every one of these mechanisms is bypassed at once.
The ordering in that decision tree is not only about correctness — it tracks ongoing cost almost exactly. Mechanisms near the top are decided once and then stop demanding attention; those near the bottom need maintenance from more people, in more repositories, indefinitely. That is a strong reason to answer the earlier questions honestly rather than reaching for the most flexible tool by default.
Common mistakes & gotchas
Using a tag to hide something from everybody. If no audience should see a field, @inaccessible says so in one place and cannot be undone by an include-list mistake in a filter nobody reviewed. A tag that happens to be excluded from every current contract is one new contract away from leaking.
Treating a filtered field as a secret. Filtering removes a field from an API schema; it does nothing about the value travelling inside the graph, appearing in logs, or being visible in a trace. Fields carrying genuinely sensitive values need handling at those layers too.
Authorizing something that should have been filtered. A field that always denies for every external caller is noise in the public schema and a hint about your internal model. If the denial never depends on who is asking, it was an audience decision.
Frequently Asked Questions
Does @inaccessible hide the field from error messages and traces too?
No. It removes the element from the API schema, which stops clients from selecting it. Values still flow inside the graph, still appear in subgraph responses to the router, and can still surface in logs, traces, or an error message that echoes a resolver’s context. Treat the schema mechanism and the data-handling mechanism as separate obligations.
Can I mark a @key field @inaccessible?
You can, and the entity still resolves — the router uses key fields internally regardless of whether clients can select them. It is a legitimate pattern when an entity is keyed on something like an internal surrogate id that has no meaning outside. What you cannot do is make the key inaccessible and then expect a client to fetch the entity by that key.
How do I stop a field being added to the public API by accident?
Make the public contract’s include list explicit and small, and diff the fetched public schema in CI. With an include-list filter, a new field is private by default — it only becomes public when someone actively tags it, and the diff makes that tagging visible in review. That default is the single most valuable property of the whole arrangement.
Is there a performance cost to any of these?
Only to authorization. Filtering and @inaccessible are resolved at composition time and cost nothing per request. Authorization directives are evaluated per request, and policy-based ones may involve a call to an external evaluator, which is why the ordering in this guide matters for latency as well as for clarity.
What should a field-by-field audit actually record?
Three columns are enough: the field, the mechanism, and the sentence explaining why. The explanation is the part that earns its keep — six months later, nobody can reconstruct whether costBasis is inaccessible because it is commercially sensitive or because it was simply never needed, and those two reasons lead to different answers when a partner asks for it. Keep the table next to the tag vocabulary so a single document answers both “what are our audiences?” and “why is this field not in one of them?”.
Do these mechanisms compose with each other?
Tags and authorization compose cleanly and usefully: a field can be tagged into the partner contract and still require a scope, so partners see it in the schema and only some of their callers can read it. @inaccessible composes with nothing, by design — an element removed from every API schema cannot also be conditionally visible, and adding a tag alongside it has no effect at all. If you find yourself wanting both, the requirement is an audience difference and the @inaccessible is wrong.
Related
- Contracts and Schema Variants for API Consumers — parent guide
- Using @tag and @inaccessible to Build Contract Variants — the directive mechanics
- Publishing a Public API Variant with Apollo Contracts — the rollout workflow
- Directive Patterns for Cross-Service Authorization — the per-caller mechanism
- Type Ownership and Shared Schema Contracts — who decides what a shared type exposes