Building and securing a GraphQL API in Rails
I’ve written a fair bit about GraphQL over the years, and more recently about building and hardening REST APIs in Rails. This post pulls all the threads together: building a GraphQL API with graphql-ruby from an empty app to queries, mutations and authentication, with security treated as part of the build rather than a bolt-on at the end.
That last bit matters more in GraphQL than anywhere else. There’s one endpoint, the client decides the shape of every request, and a single query can ask for the world. The flexibility that makes it lovely to consume is exactly what you have to fence in.
We’ll build a small invoicing API, because I have invoices on the brain: users sign in, create invoices, and query their own.
Creating the project
From nothing, then. An API-mode app is the natural fit, since GraphQL will be the only thing this app serves:
rails new invoicing --api -d postgresql
cd invoicing
rails db:create
(If your machine isn’t set up yet, that’s covered too: the mise way or the rbenv way, your pick.)
Now add GraphQL:
# Gemfile
gem "graphql"
bundle install
rails generate graphql:install
bundle install
The generator gives you an app/graphql directory with a schema and base types, a GraphqlController, a POST /graphql route, and (that second bundle install) the GraphiQL browser IDE for development.
One wrinkle worth knowing upfront: because we created the app with --api, Rails stripped out the asset pipeline and session middleware that the embedded GraphiQL page leans on, so /graphiql may greet you with errors rather than an IDE. You have two options, and I’d honestly take the second: wire the missing pieces back in just for development, or skip the embedded IDE and point a standalone GraphQL client (there are plenty; anything that speaks HTTP will do) at http://localhost:3000/graphql. A dedicated client is where you’ll live for the rest of this post anyway, and it keeps the API app pure.
Modelling and types
Plain Rails models first:
rails g model User email:string:uniq password_digest:string api_key:string:index
rails g model Invoice user:references number:string amount_pence:integer paid:boolean
rails db:migrate
class User < ApplicationRecord
has_secure_password
has_secure_token :api_key
has_many :invoices
end
Then a GraphQL type to expose invoices. A type is a declaration of what the API will admit exists, which makes it your first security boundary: nothing is exposed by accident, only fields you list.
# app/graphql/types/invoice_type.rb
module Types
class InvoiceType < Types::BaseObject
field :id, ID, null: false
field :number, String, null: false
field :amount_pence, Integer, null: false
field :paid, Boolean, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
end
end
Note what’s not there: no user_id, no api_key, nothing about the owner. Contrast that with a lazy REST endpoint spraying to_json of every column, and GraphQL’s explicit-by-default design is already earning its keep.
Queries
Fields on the root query type answer questions. Here’s the important habit, right from the first resolver: start every query from the caller.
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :invoices, [Types::InvoiceType], null: false
field :invoice, Types::InvoiceType, null: true do
argument :id, ID, required: true
end
def invoices
context[:current_user].invoices.order(created_at: :desc)
end
def invoice(id:)
context[:current_user].invoices.find_by(id: id)
end
end
end
context[:current_user].invoices.find_by(...), never Invoice.find(...). The classic API hole, in REST and GraphQL alike, is a resolver that looks records up globally and serves anyone’s data to any signed-in caller. Scope from the caller and that bug becomes unwritable. Where does current_user come from? Glad you asked.
Authentication
Everything flows through one controller action, which makes it the natural checkpoint. Authenticate the token there and hand the result to every resolver through context:
# app/controllers/graphql_controller.rb (trimmed to the interesting bits)
class GraphqlController < ApplicationController
def execute
result = AppSchema.execute(
params[:query],
variables: prepare_variables(params[:variables]),
operation_name: params[:operationName],
context: { current_user: current_user }
)
render json: result
end
private
def current_user
token = request.headers["Authorization"]&.split(" ")&.last
User.find_by(api_key: token) if token
end
end
Same token-in-header pattern as the REST post, and the same hardening advice applies: for anything serious, store a digest of the token rather than the raw value.
Notice we don’t reject unauthenticated requests at the door. Some fields must work signed-out, starting with the one that signs you in.
Mutations
Mutations are GraphQL’s writes, and they’re just resolvers with arguments and a declared return shape. First, the front door:
# app/graphql/mutations/sign_in.rb
module Mutations
class SignIn < BaseMutation
argument :email, String, required: true
argument :password, String, required: true
field :token, String, null: true
field :errors, [String], null: false
def resolve(email:, password:)
user = User.authenticate_by(email: email, password: password)
if user
{ token: user.api_key, errors: [] }
else
{ token: nil, errors: ["Invalid email or password"] }
end
end
end
end
(authenticate_by is the Rails 7.1+ way; it takes constant time whether the email exists or not, which quietly closes a user-enumeration hole.)
Now a guarded write. The ready? hook runs before anything is loaded, making it the right place for the sign-in check:
# app/graphql/mutations/create_invoice.rb
module Mutations
class CreateInvoice < BaseMutation
argument :number, String, required: true
argument :amount_pence, Integer, required: true
field :invoice, Types::InvoiceType, null: true
field :errors, [String], null: false
def ready?(**_args)
return true if context[:current_user]
raise GraphQL::ExecutionError, "Sign in required"
end
def resolve(number:, amount_pence:)
invoice = context[:current_user].invoices.build(
number: number, amount_pence: amount_pence, paid: false
)
if invoice.save
{ invoice: invoice, errors: [] }
else
{ invoice: nil, errors: invoice.errors.full_messages }
end
end
end
end
Register both on the mutation root, and note the security freebie: GraphQL arguments are explicit and typed, so there’s no mass-assignment surface. A client can’t sneak paid: true or user_id: 1 into the payload, because the mutation never declared those arguments. It’s strong parameters by construction.
module Types
class MutationType < Types::BaseObject
field :sign_in, mutation: Mutations::SignIn
field :create_invoice, mutation: Mutations::CreateInvoice
end
end
From the client, the whole flow:
mutation {
signIn(email: "[email protected]", password: "correct-horse") { token errors }
}
mutation {
createInvoice(number: "INV-042", amountPence: 25000) {
invoice { id number }
errors
}
}
Authorisation on types
Resolver scoping covers your own queries, but as a schema grows, invoices start being reachable through other paths, nested under other types, returned by other mutations. authorized? guards the type itself, so every road to it passes the same checkpoint:
module Types
class InvoiceType < Types::BaseObject
def self.authorized?(object, context)
super && object.user == context[:current_user]
end
# fields as before...
end
end
Unauthorized objects come back as null, with a pleasant side effect: the API never confirms whether the record you guessed at even exists. And when the rules outgrow “is it mine?”, Pundit slots into resolvers exactly as it does into controllers.
The attacks REST doesn’t have
Because clients compose queries, hostile clients compose horrible ones. A few donuts of nesting, invoices { user { invoices { user ... }}}, and your database is on fire. graphql-ruby ships the brakes; you have to put them on:
class AppSchema < GraphQL::Schema
mutation Types::MutationType
query Types::QueryType
max_depth 12
max_complexity 250
default_max_page_size 50
validate_max_errors 25
end
max_depth kills nested bombs, max_complexity catches wide-but-shallow ones (including alias flooding, where one query requests the same expensive field a hundred times under different names), and a default page size means no resolver can be asked for every row at once.
Two more production switches. Introspection is wonderful in development and a free map of your schema for attackers, so:
disable_introspection_entry_points unless Rails.env.development?
And rate limiting still matters. One endpoint means the built-in Rails limiter from the REST post covers the whole API in a line:
class GraphqlController < ApplicationController
rate_limit to: 100, within: 1.minute
end
Fail boring
GraphQL’s default error behaviour is chatty. Rescue the unexpected into something deliberately dull so internals never ride out in the errors array, and let validation messages through on purpose; those are for the client:
class AppSchema < GraphQL::Schema
rescue_from(StandardError) do |err, _obj, _args, _ctx, _field|
Rails.logger.error(err.full_message)
raise GraphQL::ExecutionError, "Something went wrong"
end
end
Keep the detail in your logs, with tokens filtered, as ever.
The checklist
Explicit types with nothing sensitive fielded; token in the header, user into context; sign-in via authenticate_by; ready? guarding mutations; every resolver scoped to the caller; authorized? on sensitive types; depth, complexity and page-size caps; introspection off in production; rate limiting on the controller; boring errors.
None of it is exotic. But unlike REST, GraphQL won’t nudge you toward any of it, so the discipline has to be yours. Build it in from the first resolver and it’s barely any extra work; retrofit it later and you’ll be doing archaeology on every field.