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.