yeti logo icon
Close Icon
contact us
Yeti postage stamp
We'll reply within 24 hours.
Thank you! Your message has been received!
A yeti hand giving a thumb's up
Oops! Something went wrong while submitting the form.

Fintech Security with GraphQL Shield

By
Jonny Schult
-
February 3, 2025
software developer coding

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.

Why Authorization Matters in Fintech

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.

About the Demo App

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.

Introducing GraphQL Shield

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.

Key Philosophies

  1. Composable
    You can build small, reusable rule snippets and compose them in different ways (e.g., using logical operators like and, or, chain, and race).
  2. Simple to Read
    Rules are defined in a style that mimics everyday language, making it easier to maintain and audit.
  3. Flexible
    Whether you’re doing role-based or attribute-based checks, GraphQL Shield lets you enforce any combination of policies.

Composability, Simplicity, and Readability

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:

By splitting checks into smaller rules, your overall permission schema remains easy to read, understand, and test.

Understanding and, or, chain, and race

GraphQL Shield includes several ways to combine rules:

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).

Roles in the System

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.

RBAC vs. ABAC

In many real-world fintech applications, you’ll see a combination of both RBAC and ABAC, especially when data ownership is a key factor.

GraphQL Shield Permissions

Below is the core piece of our GraphQL Shield configuration broken apart for explication. 

Rules

TL;DR

hasPermission

This 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;
                   });

isAccountAgent

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;
});

isAccountCustomer

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;
});

isSupervisor

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;
});

Shield

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,
 },
);

Applying Shield to Schema

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;
}

Gotchas

Here are a few “gotchas” you may encounter when working with GraphQL Shield and authorization in general:

  1. Forgetting to Protect Both Resolvers and Resources
    • A common oversight is only applying rules to the top-level Query or Mutation fields while neglecting the underlying type fields (e.g., 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.
  2. Overlooking “Fallback” Behavior
    • If you don’t set a 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.
  3. Mixing chain (and) and race (or) Incorrectly
    • Understanding when to use 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. 
  4. Relying on the Wrong Context
    • Rules need the correct 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.
  5. Debug Mode in Production
    • Leaving 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

Yeti designs and develops innovative digital products. If you have a project you'd like to get started on, we'd love to chat! Tell us a bit about what you're working on and we'll get back to you immediately!

Jonny is a software developer at Yeti. His technical career was borne of his interests in language, “truth,” and logic. When not coding or reading philosophy, he can be found blocking volleyballs with his face, DMing epic and frivolous DnD adventures, or watching the West Yorkshire Football Club, Leeds United, all from the Hoosier heartland of LaPorte Indiana.

You Might also like...

code on a computerManaging Perisistent Browser Data with useSyncExternalStore

Struggling to keep React state in sync across tabs and sessions? Learn how to use useSyncExternalStore to manage state persistence with localStorage and sessionStorage—without complex state management libraries. Improve performance and streamline your app’s state logic.

software developerReact Hooks 102: When to Avoid useEffect

Overusing useEffect in React can lead to inefficient components and performance issues. In this post, learn when to avoid useEffect and discover better alternatives for managing state, calculations, and user events. Optimize your React code with best practices for cleaner, faster applications.

Developer codingCross-Domain Product Analytics with PostHog

Struggling with cross-domain user tracking? Discover how PostHog simplifies product analytics across multiple platforms. Learn how to set up seamless cross-domain tracking, gain valuable marketing insights, and optimize user engagement with minimal effort. Read our step-by-step guide and example project to streamline your analytics today!

Browse all Blog Articles

Ready for your new product adventure?

Let's Get Started