Security
General security and auth in GraphQL
To view this content, buy the book! 😃🙏
Or if you’ve already purchased.
Security
Background: HTTP, Databases, Authentication
In this section, we’ll start out with an overview of general server-side security and then get to a few topics specific to GraphQL.
Computer security is protecting against:
- Unauthorized actions
- Theft or damage of data
- Disruption of service
Here are a few levels of vulnerability relevant to securing servers from the above threats, along with some methods of risk management:
- People and their devices: People that have access to our systems, like employees at our company, hosting companies, and service companies like Auth0.
- Train employees on security, including avoiding the most common malware avenues: visiting websites and opening files.
- Avoid personal use of work devices.
- Install antivirus on work computers.
- Vet employee candidates.
- Access production systems and data from a limited number of devices that are not used for high-risk activities like web browsing, clicking links in emails, or plugging in USB devices.
- Physical access: The capability to physically get to servers that store or handle our data.
- Make sure device hard drives are encrypted with complex login passwords, or locked away when not in use.
- Assess risk level of our service companies (for example AWS perimeter security).
- Network: Users being able to access our server over the internet or view data in transit.
- Keep our server IP addresses private.
- Use a DNS provider that hides our server IPs and handles DDoS attacks (like Cloudflare or AWS’s Sheild Standard, CloudFront, & Route 53).
- Force HTTPS: When a client makes a connection to our server on port 80 (unencrypted), redirect them to port 443, which will ensure all further data sent between us and the client is encrypted.
- Operating system: Hackers exploiting a vulnerability in our server OS (usually Linux).
- Apply security patches or use a PaaS or FaaS, where OS security is taken care of for us.
- Server platform: Node.js.
- Apply security updates to Node.js, or use a PaaS or FaaS, where security updates are done automatically.
- Application layer: GraphQL execution and our code. The following sections cover this area of security.
After we implement protections, we can hire a firm to do a security audit and use HackerOne to find areas we didn’t sufficiently cover.
Any system can be hacked—it’s just a matter of the level of resources put into hacking. The two largest sources relevant to companies are eCrime (criminal hacking—often financial or identity theft) and the Chinese government (stealing trade secrets from foreign companies). Most large companies have been hacked at some point to some degree.
After we have been hacked, it’s important to be able to:
- Figure out how it happened.
- Ensure the attackers no longer have access.
- Know what data was accessed.
- Recover deleted data.
For #1 and #3, we can set up access logs for our production servers, databases, and sensitive services, and for #4, we can set up automatic database backups (MongoDB Atlas has options for either snapshots or continuous backups). Step #2 depends on #1—if one of our service accounts was compromised, we can change the password. If one of our API user’s accounts was stolen (session token, JWT, or password), then we need to delete their session or re-deploy with code that blocks their JWT (and if we’re using password authentication, delete their current password hash and send a password reset email).
One important way to mitigate the damage of a database hack is hiding sensitive database fields—either by storing only hashes, in the case of passwords, or by storing fields encrypted (using an encryption key that’s not stored in the database). Then an attacker won’t know the user’s password (which they’d likely be able to use to log in to the user’s accounts on other sites), and they won’t be able to read sensitive data unless they also gain access to the encryption key.
Here are a few application-layer security risks that apply to API servers in general—not just GraphQL servers:
- Parameter manipulation: When clients alter operation arguments. We protect against this by checking arguments to ensure they’re valid, and by not trusting them (for instance, we should use the
userId
from the context instead of from an argument). - Outdated libraries: Our code depends on a lot of libraries, any of which may have security vulnerabilities that affect our app. For Node.js, we can use
npm audit
to check for vulnerabilities in our libraries. - Database injection like SQL injection and MongoDB injection
- XSS: On the client, preventing XSS involves sanitizing user-provided data before it’s added to the DOM, but on the server, we use a Content-Security-Policy header.
- Clickjacking: Use [X-Frame-Options headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/
- Race conditions, especially TOCTOU: Imagine multiple of our servers are running the same mutation from the same user at the same time. We may need to use database transactions or other logic to prevent this type of attack.
- Number processing: Bugs that involve working with numbers, including conversion, rounding, and overflows.
Auth options
Auth is an imprecise term—sometimes it’s used to mean authentication, sometimes authorization, and sometimes both. In this case, we mean both:
Authentication
Background: Authentication
The server receives a JWT or session ID in an HTTP header, which it uses to decode or look up the user. If we’re putting our GraphQL server in front of existing REST APIs, then we may want to just pass the header along to the REST APIs—they can continue doing the authentication (and authorization), returning null or returning errors that we can format as GraphQL errors.
However, usually we’ll handle user decoding in the GraphQL server. In the case of federation, we decoded the user in the gateway and passed the object in a user
header to the services. In the case of our monolith, we decoded in the context
function and provided context.user
to the resolvers.
But how does the client get the JWT or session ID in the first place? In our case, we used an external service: We opened a popup to an Auth0 site that did both signup and login and provided the client with a JWT. Other options include:
- Hosting our own identity server (for example the free, open-source Ory server).
- Adding HTTP endpoints to our GraphQL server (for example with the Passport library).
- Adding mutations to our GraphQL server (for example the accounts-js library adds
Mutation.register
,Mutation.authenticate
, etc. to our schema). - Using our hosting provider’s identity service (for example Netlify Identity if our server is hosted with Netlify Functions, or Amazon Cognito with AWS Lambda).
Hosting our own separate identity server might be the most common solution.
Authorization
After we authenticate the client, we either have their decoded token object (in the case of JWTs) or their user object (in the case of sessions). Both the token and the user object should have the user’s permissions. Permissions can be stored in different ways—usually a list of roles or scopes, or, at its most simple, as an admin
boolean field.
Once we have the user’s permission info, our server has to determine which data to allow the user to query and which mutations to allow the user to call. There are a number of different places where we can make this determination:
- Services: In the case of putting a GraphQL gateway in front of existing services that already do authorization checks, we can continue to let them do the checks.
- Context: If we only want logged-in users to be able to use our API, we can throw an
AuthenticationError
in ourcontext()
function whenever the HTTP header is missing or the decoding/session lookup fails. - Model: We can do the checks in our data-fetching code. This is the best option when we have both a GraphQL and REST API, both of which call the model code. (This way, we don’t have to duplicate authorization checks.)
- Directives: We can add directives to fields or types in our schema—for instance,
@isAuthenticated
or@hasRoles(roles: [ADMIN])
. A library we can use that defines these directives for us is graphql-auth-directives. - Resolvers: In the server we built in this chapter, we did all our authorization checks in our resolver functions. The biggest downside to this approach is repetition as the schema gets larger—for instance, we’d probably wind up with a lot of
if (!user) { throw new ForbiddenError('must be logged in') }
. It’s also harder to get a broader sense of which parts of the schema have which authorization rules. With directives, we can easily scan through the schema, and with middleware, we can look at the belowshield({ ... })
configuration and see everything together. - Middleware: We can use
graphql-middleware
—functions that are called before our resolvers are called. In particular, we can configure the GraphQL Shield middleware library to run authorization functions before our resolvers like this:
const isAuthenticated = rule({ cache: 'contextual' })(
async (parent, args, context, info) => {
return context.user !== null
}
)
const isAdmin = rule({ cache: 'contextual' })(
async (_, __, context) => {
return context.user.roles.includes('admin')
}
)
const isMe = rule({ cache: 'strict' })(
async (parent, _, context) => {
return parent._id.equals(context.user._id)
}
)
const permissions = shield({
Query: {
me: isAuthenticated,
secrets: isAdmin
},
Mutation: {
createReview: isAuthenticated
},
User: {
email: chain(isAuthenticated, isMe)
},
Secret: isAdmin
})
The equivalent directives schema would be:
type Query {
user(id: ID!): User
me: User @isAuthenticated
}
type Mutation {
createReview(review: CreateReviewInput!): Review @isAuthenticated
}
type Secret @hasRole(roles: [ADMIN]) {
key: String
}
And for User.email
, we could either do a resolver check or create a new directive.
In each of the last three authorization locations—directives, resolvers, and middleware—we have to be careful about adding rules only to our root query fields. Since our data graph is interconnected, oftentimes there will be other ways to reach a sensitive type through a connection from another field. So it’s usually necessary to add rules to types, as we do with the Secret
type above. Unfortunately, we can’t do that in resolvers—just directives and middleware.
Denial of service
This content is included in the Full edition of the book:
For more on GraphQL security, check out OWASP’s GraphQL Cheatsheet.