PluginsScope auth

Relay node authorization

Protect direct node lookups and use scopes to distinguish refetchable access from access granted through a parent field.

Registering a Relay node creates another way to reach an object: Query.node and Query.nodes can load it from its global ID. A filter or authorization check on Query.articles, a connection, or a parent relationship does not run when a client uses that lookup. Global IDs identify objects; they are not proof that a caller may read them.

This applies to builder.node, builder.prismaNode, and builder.drizzleNode. Decide which objects should be refetchable and what access a direct lookup should allow. A type can participate in a connection without implementing Node.

Choose a default policy

For an API where direct refetches require a signed-in user, put that requirement on both generated lookup fields. These options belong in the builder's relay configuration, alongside the Scope Auth setup shown below. The private-policy example combines these gates with the Node interface and Query defaults discussed next; the original example keeps its public featured-article path:

nodeQueryOptions: {
  authScopes: { loggedIn: true },
},
nodesQueryOptions: {
  authScopes: { loggedIn: true },
},

These field checks run before Relay's node resolver, so an anonymous request cannot trigger its node loaders, even with a __typename-only selection. A successful login check is only a baseline: keep resource-specific permissions on the returned types or in their loaders. These options do not protect custom fields that return the same objects or use node-loading helpers themselves.

You can also put a shared policy on the Node interface:

nodeTypeOptions: {
  authScopes: { loggedIn: true },
},

This applies the interface requirement to fields of its implementing objects, including objects returned through ordinary root fields, relationships, and connections. It adds to each object's own scopes; satisfying one does not bypass the other. For example, a signed-in reader can still fail an article's separate readArticle permission check.

Interface scopes are useful for a shared baseline, but their default execution still happens at the object's fields, after loading. They do not hide __typename. Use the root-field gates when you also want to reject anonymous node lookups before loading. The two configurations can be used together. An object or field can opt out of interface checks with skipInterfaceScopes: true, so audit these exceptions as part of the policy.

An interface login requirement would also block the anonymous featured-article path later in this guide. Its parent grant satisfies the article's own policy; it does not override a separate interface requirement. Choose a baseline that matches which parts of your API are public.

Make Query fields private by default

If most of the API requires login, authScopes on builder.queryType also applies to the generated node and nodes fields. Public fields can explicitly skip the Query type requirement:

builder.queryType({
  authScopes: { loggedIn: true },
  fields: (t) => ({
    serviceName: t.string({
      skipTypeScopes: true,
      resolve: () => 'Publishing API',
    }),
  }),
});

This is a Query-root policy, not a schema-wide authorization default. Configure Mutation and Subscription separately, and keep entity-level checks for access through other paths. Field scopes still apply when skipTypeScopes is used; it only removes the parent type's requirement. scopeAuth.defaultStrategy controls whether scopes in a map use AND or OR, not whether unannotated fields are private.

If direct refetching is not part of the API, set both relay.nodeQueryOptions and relay.nodesQueryOptions to false. Node types and their IDs can remain in the schema without those generated root lookups; custom lookup fields still need their own policy.

Apply a policy to the returned object

Type authScopes let the same policy protect fields whether the object came from a root field, a relationship, a connection, or a node lookup. Parameterized scopes are useful for checks such as ownership, project membership, or permission to read a particular article. Their results are cached by scope name and parameter for the request.

In this example, readers with the readArticle permission may read articles through any path. Other readers can read the featured article through a field that explicitly grants access. The builder defines a permission scope using the current user:

export const builder = new SchemaBuilder<{
  Context: Context;
  AuthScopes: { loggedIn: boolean; permission: Permission };
}>({
  plugins: [ScopeAuthPlugin, RelayPlugin],
  scopeAuth: {
    authScopes: (context) => ({
      loggedIn: !!context.user,
      permission: (permission) => context.user?.permissions.includes(permission) ?? false,
    }),
    unauthorizedError: () => new Error('Not authorized'),
  },
});

The node accepts either the read permission or a grant from its parent field:

const ArticleNode = builder.objectRef<Article>('Article');

builder.node(ArticleNode, {
  id: { resolve: (article: Article) => article.id },
  loadOne: (id) => {
    return articles.find((article) => article.id === id) ?? null;
  },
  authScopes: {
    $any: {
      permission: 'readArticle',
      $granted: 'readFeaturedArticle',
    },
  },
  fields: (t) => ({
    title: t.exposeString('title'),
  }),
});

$any makes this an explicit choice between two authorization paths. For policies requiring both membership and a resource permission, use $all instead. A scope parameter should identify the resource or permission being checked; include every value that changes the decision in its cache key when using object parameters.

The loader retrieves the article before scopes run. By default, type scopes run before the object's individual field resolvers. Those fields are protected, but __typename does not use a Pothos field resolver. The existence-check operation therefore returns Article even for an anonymous reader. Field-level checks do not hide existence.

For an ordinary, path-independent policy, runScopesOnType: true moves type checks to GraphQL object completion, including selections containing only __typename. Denial then produces an error at the field returning the object. It still does not prevent the loader from running. Review its execution compatibility and override limitations. Keep the parent-grant pattern below on the default field-level execution: parent grants are not resolved at the same response path when scopes run through isTypeOf.

Grant access through a particular path

A featured-article field can deliberately allow a reader to view one selected article without making every article available through node lookups:

builder.queryField('featuredArticle', (t) =>
  t.field({
    type: ArticleNode,
    nullable: true,
    grantScopes: ['readFeaturedArticle'],
    resolve: () => {
      return articles[0];
    },
  }),
);

grantScopes applies to the returned object's response path. It does not add an ordinary scope to the request, and it does not turn the returned ID into a transferable permission. An anonymous reader can receive the featured article and still be denied when refetching the same ID:

query ArticleAccessPaths {
  featuredArticle {
    id
    title
  }
  node(id: "QXJ0aWNsZTox") {
    ... on Article {
      id
      title
    }
  }
}

Run both access paths. The featured article resolves, while node is null with a Not authorized error at node.id. The Relay ID is non-null, so its denied field check nulls the containing node. A reader with the readArticle permission can refetch it directly. This distinction can be useful for previews or access granted by a parent resource, but clients must not assume every ID they receive can be refetched successfully.

Grants are not inherited by nested children. A grant on a connection field does not automatically reach objects beneath edges.node; put the policy on the returned entity, or deliberately propagate the grant through the intervening fields. Likewise, a grant on one alias does not authorize another alias or a separate node / nodes selection. The underlying object or permission result may be cached while its response paths still have different granted scopes.

When access should follow the entity across all paths, use an ordinary scope such as canReadArticle with the article ID, and use that same permission policy on the object regardless of its parent. Reserve parent-field grants for access that is intentionally tied to that path. Field scopes can then add stricter permissions for sensitive fields, such as an article's editorial notes, without restricting its public title.

Use a static type scope map for this parent-grant pattern, as above. An authScopes function on a type caches its evaluated result for the returned object instance, so it is not suitable for combining a path-dependent grant with an object-dependent check. Field authScopes functions are another option when a check needs the current field's arguments or parent data. Avoid deriving path-dependent permissions in a cached type callback.

Decide what node loading may reveal

If an unauthorized entity should behave like a missing one, enforce visibility in the node loader or the data-access layer it uses. Return null for both cases if that is the API's policy. Do not rely on a type scope to prevent a database read, an eager relationship load, or side effects in a loader. With loadMany, check every requested ID and preserve the input order, including missing or inaccessible entries.

The database integrations supply their own node loaders. A where clause in an unrelated list resolver does not configure them. See Prisma Relay nodes and Drizzle Relay nodes for integration-specific guidance. Keep an entity as an ordinary object if its direct lookup policy is not implemented.

A failed scope normally produces a GraphQL error; a loader returning null can represent absence without an authorization error. Null propagation still follows the configured field and list-item nullability, so a non-null node field or item can affect a larger part of the response. Returning null alone is not a guarantee against every form of information disclosure, such as differences in errors or timing.

Keep authorization tied to the request

Relay's loadOne and loadMany cache node loads on the context, and Scope Auth caches scope initialization and permission decisions for that context. Use a fresh context for each request and keep its authenticated identity stable. A cache hit is not a new permission check; do not reuse a context between users or expect changing its user midway through an operation to invalidate caches. Database plugin loaders can have additional request caches of their own.

Exercise the direct path as well as the intended parent path: an allowed user, a denied user, a missing ID, a mixed nodes request, and a selection containing only __typename. For grant-based policies, request the same object through both a granted path and a direct lookup in one operation. This catches accidental assumptions that receiving an ID or loading an object grants access to it.