In the modern fintech landscape, ensuring that only the right users have access to the right data is crucial. For GraphQL-based applications, this can be tricky—GraphQL provides a flexible schema that can inadvertently become a security liability if not carefully protected.
Enter GraphQL Shield (Docs) provided by the open source developers at The Guild (link). This library provides a straightforward yet powerful way to implement fine-grained authorization rules on top of your GraphQL API.
In this post, we’ll walk through why a robust authorization layer matters, how GraphQL Shield helps address common security concerns, and how you can use it with role-based access control (RBAC) in a fintech context. We’ll also touch on attribute-based access control (ABAC) concepts, composable rules, and best practices for making your security layer both easy to read and flexible to maintain.
When building any application that deals with sensitive data—especially in fintech—there’s a pressing need to ensure that end-users cannot perform unauthorized operations or view data they shouldn’t see. A well-structured authorization layer prevents:
In a nutshell, a good authorization approach can save you from security nightmares and legal liabilities down the line.
To illustrate these concepts, we’ve put together a simple demo application (hosted on my GitHub). To set it up and run it locally, follow the README. This gives you a sandbox to modify the schema and create other mutations and queries to protect and an easy to use frontend interface that will allow you to test out the authorization.
This application is intentionally minimal and does not handle authentication. Rather, it’s a dummy app designed to demonstrate how to apply GraphQL Shield for authorization logic in a straightforward way.
Note: The non-authorization code for the frontend and backend in this demo are not necessarily built with best practices in mind. They’re purely intended for demonstration purposes. The focus is on how you might secure your GraphQL schema with RBAC, ABAC, and GraphQL Shield. In a real-world application, you would integrate a proper authentication flow and refine the backend design according to production standards.
GraphQL Shield is a library that adds an authorization layer to your GraphQL schema. It allows you to declare rules that determine if a user has the rights to access a particular resource or perform a certain action.
and, or, chain,
and race
).One of the biggest advantages of GraphQL Shield is how it encourages you to define modular rules. Each rule typically represents a single permission check—for example, verifying that a user has a specific role or can access a specific account. You can then compose these rules to handle complex logic.
For instance, you might define separate rules for:
READ_Account
or WRITE_Account
permission.By splitting checks into smaller rules, your overall permission schema remains easy to read, understand, and test.
GraphQL Shield includes several ways to combine rules:
and(ruleA, ruleB, ...)
: All rules must pass. This is analogous to a logical AND. or(ruleA, ruleB, ...)
: At least one rule must pass. This is analogous to a logical OR. chain(ruleA, ruleB, ...)
:
Processes rules in a sequence and fails fast if any rule in the chain fails. This is handy when each rule depends on the success or context changes of the previous rule.ace(ruleA, ruleB, ...)
: Similar to or, but allows access as soon as one rule passes. If the first rule in the group passes, the others are not evaluated.In our example below, we use and where each rule must succeed, and race–a slight optimization over or due to short-circuiting behavior–for checks where only one rule needs to succeed (such as checking if a user is either an agent, customer, or supervisor).
In a typical fintech application, you might have several roles:
These roles each come with distinct permissions, typically defined by actions (READ, WRITE) and resources (Account, Transaction, etc.). This is the essence of Role-Based Access Control (RBAC). For simplicity, in the demo application we only have supervisor
, agent
, and customer
.
In many real-world fintech applications, you’ll see a combination of both RBAC and ABAC, especially when data ownership is a key factor.
Below is the core piece of our GraphQL Shield configuration broken apart for explication.
hasPermission
checks that the user making the request has the proper access rule for the proper resourceisAccountAgent
checks that the user is the agent for the account requestedisAccountCustomer
checks that the user is the customer of the account requestedisSupervisor
checks that the user has the supervisor roleThis function, hasPermission
, creates a GraphQL Shield rule that checks whether the current user has a specific permission (e.g., "WRITE_Account
"). Internally, it splits the permission string on the underscore to separate the action (e.g., "WRITE
") from the resource (e.g., "Account"). Then, it looks up the user’s assigned role and verifies that the role’s permission set includes any entry matching both the same action and resource. If such a match is found, it returns true
; otherwise, it returns false
. This makes it easy to enforce fine-grained, role-based access controls within your GraphQL schema by passing in different permission strings as needed.
const hasPermission = (permission: string) =>
rule({ cache: 'contextual' })((_parent, _args, context: Context) => {
const [action, resource] = permission.split('_');
const perms = context.user?.role.permissions.some(
(p) => p.resource === resource && p.action === action,
);
return perms ?? false;
});
This isAccountAgent
rule implements an attribute-based check to ensure the current user is the agent of the requested account. It does so by first verifying that an accountId
was provided in the query arguments. If present, it retrieves the account record from the database, then compares the agentId
of that account to the user’s ID in the request context. If they match, the function returns true
, indicating that the user is indeed the agent for that account; otherwise, it returns false
.
const isAccountAgent = rule({ cache: 'contextual' })(async (
_parent,
args,
context: Context,
) => {
if (!args.accountId) return false;
const accounts = await context.prisma.account.findUnique({
where: {
id: args.accountId,
},
});
let isAgent = accounts?.agentId === context.user?.id;
return isAgent;
});
This rule determines whether the current user is the customer associated with the requested account. It checks if an accountId
is present in the query arguments, looks up the corresponding account in the database, and compares the account’s customerId
against the user’s ID from the request context. If they match, the rule grants access; otherwise, it denies it.
const isAccountCustomer = rule({ cache: 'contextual' })(async (
_parent,
args,
context: Context,
) => {
if (!args.accountId) return false;
const accounts = await context.prisma.account.findUnique({
where: {
id: args.accountId,
},
});
let isCustomer = accounts?.customerId === context.user?.id;
return isCustomer;
});
This rule checks if the current user’s role is set to “Supervisor.” By examining the user’s role name in the request context, it ensures that only supervisors can perform the protected operation. If the user’s role name matches “Supervisor,” the rule returns true
, granting access; otherwise, it returns false
.
const isSupervisor = rule({ cache: 'contextual' })(async (
_parent,
_args,
context: Context,
) => {
const isSupervisor = context.user?.role.name === 'Supervisor';
return isSupervisor;
});
The shield definition ties together all of the individual rules to comprehensively protect the schema. By organizing rules for each Query, Mutation, and type, you ensure that every request is explicitly authorized. In particular, applying rules to resource types is crucial because type fields can still be queried after a top-level resolver succeeds—potentially exposing sensitive data if left unprotected. The use of and (chain)
and race (or)
makes complex authorization logic straightforward to read and maintain. Moreover, optimizations can be performed by knowing that each rule is invoked in either a sequential (chain
) or parallel (race, or, and
) manner.
Setting the fallbackRule
to deny is a good security practice as it forces you to explicitly provision permissions for each resolver, preventing accidental public exposure of data. Additionally, enabling debug: true
in development environments can help you diagnose any authorization issues by providing more detailed output, while in production you typically disable it (or fine-tune options like allowExternalErrors
) to avoid leaking internal errors to end users.
export const permissions = shield(
{
Query: {
accountDetails: and(
hasPermission(Permission.READ_Account),
race(isAccountAgent, isAccountCustomer, isSupervisor),
),
'*': deny,
},
Account: {
'*': and(
race(hasPermission(Permission.READ_Account)),
race(isAccountAgent, isAccountCustomer, isSupervisor),
),
},
UpdateAccountResult: {
success: and(
hasPermission('WRITE_Account'),
race(isAccountAgent, isSupervisor),
),
},
Mutation: {
updateBalance: and(
hasPermission('WRITE_Account'),
race(isAccountAgent, isSupervisor),
),
},
},
{
fallbackRule: deny,
},
);
In this snippet, we wrap our existing GraphQL schema
with permissions
using applyMiddleware
to create a protectedSchema. By passing that protectedSchema
to the ApolloServer
constructor (GraphQL shield works with GraphQL servers beside Apollo as well), we ensure all incoming GraphQL operations are checked against the Shield rules before being executed. This setup centralizes the authorization logic in one place, making it easy to maintain and reason about, while keeping the rest of the server configuration (e.g., logging, plugins) cleanly separated.
export function createApolloServer(httpServer: http.Server) {
const protectedSchema = applyMiddleware(schema, permissions);
const server = new ApolloServer({
schema: protectedSchema,
introspection: true,
...(NODE_ENV !== 'test' ? { logger: logger } : {}),
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
...(NODE_ENV !== 'test' ? [pinoLogger] : []),
],
});
return server;
}
Here are a few “gotchas” you may encounter when working with GraphQL Shield and authorization in general:
Account
). If you don’t also protect the fields on those types, users might still query sensitive data through related fields. In other words, remember to apply rules to both the resolver (like accountDetails
) and the resource type (Account
) itself.fallbackRule
, GraphQL Shield could default to allowing unintended access. Conversely, if you set it to deny but then forget to explicitly allow certain operations, you might accidentally lock out users from legitimate queries or mutations. Always double-check you’ve set the fallback and spelled out every allowance needed.chain
(
and
)
and race
(or
) Incorrectly chain, and, race, or
is important. Mixing them up might inadvertently allow access you intended to restrict, or block access you intended to grant. If your authorization logic depends on passing multiple checks, make sure you’re using chain (and)
rather than race (or)
. If the checks need to be sequential, be sure to use chain. context
(i.e., the user’s role, permissions, or attributes) passed in from your server setup. If you accidentally use a different context or forget to add user info to it, your rules may fail or always pass. Always confirm that the context in your rules is the same one passed to your GraphQL server.debug: true
or detailed error messages in a production environment can leak internal implementation details to unauthorized users. Ensure you toggle these settings off (or fine-tune allowExternalErrors
) before going live to avoid exposing sensitive error information.By keeping these pitfalls in mind—especially protecting both the top-level resolvers and the resource types—you’ll be better equipped to create a robust, secure authorization layer with GraphQL Shield.
Building a secure fintech application requires careful thought around both authentication (identifying who a user is) and authorization (ensuring they only do what they’re allowed). GraphQL Shield provides a clean, expressive way to define your authorization logic within your GraphQL schema.
In this demo, you’ve seen how to apply RBAC by assigning permissions to roles and ABAC by checking user attributes against resource ownership. Combining composable rules with logical operators (chain, race
, etc.) keeps your code both secure and maintainable.
Remember: this demo is purely illustrative. You’d still want to integrate an actual authentication solution (e.g., with JWTs, session tokens, or OAuth) and solidify your production code. However, GraphQL Shield can serve as the foundation of your authorization layer, whether you’re starting out or revamping an existing system.
Thanks for reading! If you’d like to dive deeper, check out the demo app here and explore how we’ve integrated these rules. Feel free to experiment and tailor them to your own fintech use cases.
Happy Hacking