Scope auth
Guard fields and types with authorization scopes, scope loaders, and logical combinations.
The scope-auth plugin lets you name the checks your app cares about ("is this a member," "is this staff," "can this user manage that team") and attach them to any field or type with an authScopes option, so your authorization rules live on the schema itself. Checks run before the resolver, and their results are cached per request.
You define the scopes once on the builder, then require them wherever they matter:
Team.implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
// Only signed-in members see the standings.
wins: t.exposeInt('wins', {
authScopes: {
member: true,
},
}),
// Scouting reports are staff-only.
scoutingReport: t.exposeString('scoutingReport', {
authScopes: {
staff: true,
},
}),
}),
});Open the example and empty the Context tab: wins and scoutingReport now fail while id and name still resolve. The plugin doesn't assume a particular auth model, so the same setup supports role-based, permission-based, or ownership-based schemes.
Setup
Install the plugin and list it in plugins. When you combine it with other plugins, put scope-auth first so plugins that wrap resolvers don't run before the auth check.
npm install --save @pothos/plugin-scope-authTwo pieces make up the configuration: the AuthScopes type, which names your scopes and the parameter each one takes, and the scope initializer, which builds those scopes for the current request.
import SchemaBuilder from '@pothos/core';
import ScopeAuthPlugin from '@pothos/plugin-scope-auth';
const builder = new SchemaBuilder<{
Context: { user?: { id: string; role: 'member' | 'staff' } };
// Each scope name maps to the type of the parameter its loader takes.
AuthScopes: {
public: boolean;
member: boolean;
staff: boolean;
};
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
authScopes: async (context) => ({
public: true,
member: !!context.user,
staff: context.user?.role === 'staff',
}),
},
});The scope names are yours; public, member, staff are arbitrary labels, not plugin keywords. Add as many as you need.
The relay plugin is the notable exception to the "scope-auth first" rule. List relay before scope-auth so authScopes functions receive already-parsed globalIDs rather than raw strings.
Terminology
A few terms recur below:
- Scope: one unit of authorization you can require on a field or type.
- Scope map: the object you pass to
authScopes, scope names to parameters. - Scope loader: a function that resolves a scope from a parameter, ideal for permission services.
- Scope parameter: the value passed to a loader (the values in a scope map).
- Scope initializer: the
authScopesfunction on the builder that creates a request's scopes.
Booleans vs scope loaders
A scope in the initializer is either a boolean (the request has it or not) or a loader, a function that takes a parameter and returns MaybePromise<boolean>. Booleans decide request-wide facts up front; loaders answer parameterized questions on demand.
authScopes: async (context) => ({
// Eagerly evaluated once per request.
member: !!context.user,
// A loader: called with the parameter each field supplies.
canManageTeam: (teamId: string) =>
context.permissions.canManage(context.user, teamId),
});A loader runs only when a field requires it, and its result is cached per request by scope name and parameter. If you already know a loader would always fail for this request, short-circuit it with false to skip the work entirely:
authScopes: async (context) => ({
// Bots never have permissions — don't even build the loader for them.
canManageTeam: context.user
? (teamId: string) => context.permissions.canManage(context.user!, teamId)
: false,
});You can also defer a plain boolean by wrapping it in a function (() => context.user.isStaff()) so it's evaluated on first use rather than during initialization.
Requiring a scope
authScopes attaches to root fields, object fields, and interface fields alike. Listing several scopes checks them together; by default the request passes if it has any of them.
builder.mutationType({
fields: (t) => ({
reportScore: t.boolean({
authScopes: { staff: true },
resolve: () => true,
}),
}),
});Default scopes for a whole type
To apply the same requirement to every field of a type, put authScopes in the type options instead of on each field:
Team.implement({
authScopes: { member: true },
fields: (t) => ({
name: t.exposeString('name'),
wins: t.exposeInt('wins'),
}),
});Type-level scopes and field-level scopes both run before a field resolves. When a type scope fails you get one error per affected field, though the check itself runs only once.
Overriding the type default
Add scopes on a field to require more than the type default. To drop the type default for one field, set skipTypeScopes, optionally alongside a field authScopes to replace it wholesale:
Team.implement({
authScopes: { member: true },
fields: (t) => ({
// Public even though the type defaults to members-only.
name: t.exposeString('name', {
skipTypeScopes: true,
}),
wins: t.exposeInt('wins'),
}),
});skipInterfaceScopes does the same for scopes inherited from interfaces.
Combining scopes with $any and $all
A scope map is an OR by default. Use the built-in $any and $all keys to build precise boolean logic; they take scope maps and nest freely:
Team.implement({
fields: (t) => ({
scoutingReport: t.exposeString('scoutingReport', {
authScopes: {
$all: {
$any: { staff: true, member: true },
public: true,
},
},
}),
}),
});That requires public and (staff or member). To flip the top-level default from any to all, set the DefaultAuthStrategy type and the matching defaultStrategy option:
const builder = new SchemaBuilder<{
Context: Context;
AuthScopes: { member: boolean; staff: boolean };
DefaultAuthStrategy: 'all';
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
defaultStrategy: 'all',
authScopes: async (context) => ({
member: !!context.user,
staff: context.user?.role === 'staff',
}),
},
});Dynamic scopes
Field-specific parameters
When you can't enumerate every scope ahead of time (a permission service, a per-resource check), pass a parameter to a loader. The map value is the argument:
builder.mutationField('renameTeam', (t) =>
t.boolean({
args: { teamId: t.arg.string({ required: true }) },
authScopes: (_parent, args) => ({ canManageTeam: args.teamId }),
resolve: () => true,
}),
);The parameter types come straight from the AuthScopes type you declared on the builder.
Scopes that depend on the parent
When the required scopes depend on the resolved value, use a function for authScopes. On a field it receives the same arguments as the resolver; returning a boolean is a shortcut to allow or deny without naming other scopes:
Team.implement({
fields: (t) => ({
scoutingReport: t.exposeString('scoutingReport', {
authScopes: (team, _args, context) => {
// The team's own coach always has access.
if (context.user?.id === team.coachId) {
return true;
}
// Everyone else needs staff.
return { staff: true };
},
}),
}),
});A field authScopes function runs every time the field resolves (including once per alias) because it depends on the resolver arguments.
A type can take a function too. It receives (parent, context) and returns a scope map, evaluated lazily on the first field of each instance:
Team.implement({
authScopes: (team) => (team.isPublic ? { public: true } : { staff: true }),
fields: (t) => ({
name: t.exposeString('name'),
}),
});Setting scopes based on a field's return value isn't supported directly; the check runs before the resolver. Move the check onto the returned type instead, and use runScopesOnType so it fires once per object.
Granting access with $granted
Sometimes a field should be reachable because of how it was reached. $granted scopes are one-off grants passed down from a parent field or type, separate from your normal scopes, never inherited by nested children.
A field grants scopes with grantScopes; the child type requires them with $granted:
builder.queryType({
fields: (t) => ({
featuredTeam: t.field({
type: Team,
grantScopes: ['readTeam'],
resolve: () => getFeaturedTeam(),
}),
}),
});
Team.implement({
authScopes: {
member: true,
$granted: 'readTeam',
},
fields: (t) => ({
name: t.exposeString('name'),
}),
});A Team normally needs member, but anyone arriving through featuredTeam reads it anyway. grantScopes can also be a function of the resolver arguments.
A type can grant scopes to its own fields, sharing one condition across a group of fields without repeating it:
Team.implement({
grantScopes: (team, context) => {
if (context.user?.id === team.coachId) {
return ['coach', 'readTeam'];
}
return team.isDraft ? [] : ['readTeam'];
},
fields: (t) => ({
name: t.exposeString('name', { authScopes: { $granted: 'readTeam' } }),
wins: t.exposeInt('wins', { authScopes: { $granted: 'readTeam' } }),
scoutingReport: t.exposeString('scoutingReport', {
authScopes: { $granted: 'coach' },
}),
}),
});Running scopes on the type
By default every scope, type-level included, is tested at the field level, so a failed type scope surfaces an error on each field. Set runScopesOnType to check the type once, on the object itself, either per type or globally in scopeAuth:
const builder = new SchemaBuilder<{
Context: Context;
AuthScopes: { member: boolean };
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
// Applies to all object types except Query, Mutation, and Subscription.
runScopesOnType: true,
authScopes: async (context) => ({ member: !!context.user }),
},
});
Team.implement({
runScopesOnType: true,
authScopes: { member: true },
fields: (t) => ({
name: t.exposeString('name'),
}),
});runScopesOnType uses GraphQL's isTypeOf and has two limits. It does not work with graphql-jit, which doesn't support async isTypeOf or pass context to it. And fields of a type that sets it can't use skipTypeScopes or skipInterfaceScopes, since type scopes no longer run at the field level.
Interfaces
Interfaces declare authScopes on their fields exactly like objects. A field runs the checks for each interface its type implements, separately; the request must satisfy every one. An object type can set skipInterfaceScopes: true to opt out of its interfaces' checks.
Typed context per scope
A scope often narrows what you know about the context; once member passes, user is non-null. Declare that refinement with the AuthContexts type and read it through t.authField:
const builder = new SchemaBuilder<{
Context: { user?: { id: string } };
AuthScopes: { member: boolean };
AuthContexts: { member: { user: { id: string } } };
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
authScopes: async (context) => ({ member: !!context.user }),
},
});
builder.queryField('currentUserId', (t) =>
t.authField({
type: 'ID',
authScopes: { member: true },
// context.user is non-null here.
resolve: (_parent, _args, context) => context.user.id,
}),
);Some plugins add field-builder methods that t.authField doesn't cover. For those, t.withAuth returns a field builder with the scopes already applied, so you can chain a plugin method onto it:
builder.queryField('me', (t) =>
t.withAuth({ member: true }).prismaField({
type: 'User',
resolve: (query, _root, _args, context) =>
prisma.user.findUniqueOrThrow({ ...query, where: { id: context.user.id } }),
}),
);Customizing the unauthorized error
By default a failed check throws a ForbiddenError. Override the message or the error instance globally through scopeAuth, or per field:
const builder = new SchemaBuilder<{
Context: Context;
AuthScopes: { member: boolean };
}>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
unauthorizedError: (_parent, _context, _info, _result) => new Error('Not authorized'),
authScopes: async (context) => ({ member: !!context.user }),
},
});The callback receives the field's parent, context, and info, plus a result argument carrying the default message and a failure describing what went wrong. Return an Error (or subclass) or a string; a string becomes a ForbiddenError. The field-level unauthorizedError takes the resolver's arguments plus result:
builder.queryField('roster', (t) =>
t.field({
type: [Team],
authScopes: { member: true },
unauthorizedError: (_parent, _args, _context, _info, _result) =>
new Error('Sign in to view the roster'),
resolve: () => getRoster(),
}),
);Surfacing errors thrown inside checks
By default, an error thrown inside an authScopes function is not caught; it behaves as if thrown from the resolver, bypassing unauthorizedError and failing even a passing $any. Set treatErrorsAsUnauthorized to catch those errors and treat them as a failed scope; the caught error is then attached to the result so you can inspect or re-throw it. The AuthFailure and AuthScopeFailureType exports let you walk the failure tree:
import ScopeAuthPlugin, { AuthFailure, AuthScopeFailureType } from '@pothos/plugin-scope-auth';
function throwFirstError(failure: AuthFailure) {
if ('error' in failure && failure.error) {
throw failure.error;
}
if (
failure.kind === AuthScopeFailureType.AnyAuthScopes ||
failure.kind === AuthScopeFailureType.AllAuthScopes
) {
for (const child of failure.failures) {
throwFirstError(child);
}
}
}
const builder = new SchemaBuilder<{ Context: Context; AuthScopes: { member: boolean } }>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
treatErrorsAsUnauthorized: true,
unauthorizedError: (_parent, _context, _info, result) => {
throwFirstError(result.failure);
return new Error('Not authorized');
},
authScopes: async (context) => ({ member: !!context.user }),
},
});Returning a value instead of an error
To return null, an empty list, or any fallback rather than erroring, use unauthorizedResolver. It takes the resolver's arguments plus a fifth ForbiddenError argument:
builder.queryField('teams', (t) =>
t.field({
type: [Team],
authScopes: { member: true },
resolve: () => getTeams(),
unauthorizedResolver: () => [],
}),
);When checks run and caching
Auth results are cached per request so shared scopes cost nothing after the first check:
- Scope initializer: runs once, the first time any protected field resolves; the result is cached for the request.
authScopesfunction on a field: runs on every resolve of that field, since it depends on the resolver arguments.authScopesfunction on a type: runs once per instance in the response, lazily on the first field, then cached for that instance.- Scope loaders: run per unique parameter, cached by scope name and parameter.
grantScopeson a field: runs after the field resolves; not cached.grantScopeson a type: runs on the first field of each instance, then cached for that instance.
Scopes cache on the identity of their parameter. Primitive parameters cache perfectly; if a loader takes an object built inside a scope function you'll miss the cache. Provide a cacheKey to derive a stable key:
const builder = new SchemaBuilder<{ Context: Context; AuthScopes: { member: boolean } }>({
plugins: [ScopeAuthPlugin],
scopeAuth: {
cacheKey: (value) => JSON.stringify(value),
authScopes: async (context) => ({ member: !!context.user }),
},
});JSON.stringify handles most objects; for circular references or key-order stability, use something like faster-stable-stringify.
Subscriptions
When authorizing subscriptions, set authorizeOnSubscribe so checks run when the subscription is created rather than when each event resolves:
scopeAuth: {
authorizeOnSubscribe: true,
authScopes: async (context) => ({ member: !!context.user }),
}Testing
Pass disableScopeAuth to toSchema to build the schema with every check turned off, for tests that shouldn't thread auth context through every query:
const schema = builder.toSchema({ disableScopeAuth: true });Reference
scopeAuth builder options
| Option | Type | Purpose |
|---|---|---|
authScopes | (context) => MaybePromise<ScopeLoaderMap> | The scope initializer. Required. |
runScopesOnType | boolean | Check type scopes once via isTypeOf instead of per field. |
treatErrorsAsUnauthorized | boolean | Catch errors thrown in checks and treat them as failed scopes. |
unauthorizedError | (parent, context, info, result) => Error | string | Global unauthorized error/message. |
cacheKey | (value) => unknown | Derive a stable cache key for object scope parameters. |
defaultStrategy | 'any' | 'all' | Top-level combination strategy. Defaults to any. |
authorizeOnSubscribe | boolean | Run subscription checks at subscribe time. |
When another plugin already supplies authScopes, pass the remaining options through scopeAuthOptions instead.
Builder types
AuthScopes: each key names a scope; its value is the type of that scope's parameter.AuthContexts: per-scope context refinements read viat.authField.DefaultAuthStrategy:'any'(default) or'all'.
Type and interface options
| Option | Type |
|---|---|
authScopes | ScopeMap or (parent, context) => MaybePromise<ScopeMap> |
grantScopes | (parent, context) => MaybePromise<string[]> |
runScopesOnType | boolean |
skipInterfaceScopes | boolean (objects only) |
Field options
| Option | Type |
|---|---|
authScopes | ScopeMap or (parent, args, context, info) => MaybePromise<ScopeMap> |
grantScopes | string[] or a function of the resolver arguments |
skipTypeScopes | boolean |
skipInterfaceScopes | boolean |
unauthorizedError | (parent, args, context, info, result) => Error | string |
unauthorizedResolver | resolver arguments plus a ForbiddenError, returns a fallback value |
A ScopeMap is scope names to parameters, plus the special $any, $all, and $granted keys. The t.authField and t.withAuth field-builder methods apply scopes while refining context.