Authentication in GraphQL

Introduction to Authentication in GraphQL

In this lesson, we're diving into how to add authentication to your GraphQL server. Authentication is crucial for securing your API and ensuring that only authorized users can access specific resources. We'll be using graphql-ruby, the most popular GraphQL implementation for Ruby, along with Sinatra as our web framework.

Our goal for this lesson is to:

  • Set up a GraphQL server with basic authentication using graphql-ruby and Sinatra.
  • Implement a login system.
  • Secure certain GraphQL mutations.

Setting Up the GraphQL Schema

First, let's set up our GraphQL types and mock data. Our GraphQL server will have two mutations — one for logging in using the provided username and password, and another for adding a new book given its author and title.

Ruby
require 'graphql'
require 'sinatra'
require 'json'
require 'securerandom'

# Mock data
USERS = [{ username: 'admin', password: 'admin' }]
BOOKS = [
  { id: '1', title: 'The Hobbit', author: 'J.R.R. Tolkien' },
  { id: '2', title: 'Harry Potter', author: 'J.K. Rowling' }
]

# Define the Book type
class BookType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :title, String, null: false
  field :author, String, null: false
end

# Define the Query type
class QueryType < GraphQL::Schema::Object
  field :books, [BookType], null: true

  def books
    BOOKS
  end
end

# Define the Mutation type
class MutationType < GraphQL::Schema::Object
  field :login, String, null: true do
    argument :username, String, required: true
    argument :password, String, required: true
  end

  field :add_book, BookType, null: true do
    argument :title, String, required: true
    argument :author, String, required: true
  end
end

A note on field naming: We define the field as add_book using Ruby's snake_case convention, but graphql-ruby automatically converts field names to camelCase in the GraphQL schema. This means clients will call this mutation as addBook. Similarly, add_book becomes addBook in queries. This is the default behavior of graphql-ruby and applies to all field names.

Implementing Authentication Logic

Now, let's implement the authentication logic to secure our GraphQL API.

For this example, we use a simple array to mock a user database.

Ruby
USERS = [{ username: 'admin', password: 'admin' }]

The login mutation takes a username and password and returns an authentication token if valid.

Ruby
def login(username:, password:)
  user = USERS.find { |u| u[:username] == username && u[:password] == password }
  if user.nil?
    raise GraphQL::ExecutionError, 'Invalid credentials'
  end
  'token'
end

Here, we check if the provided credentials match any user in our mock database. If they do, we return a token; otherwise, we raise a GraphQL::ExecutionError.

Then, we secure the add_book mutation by checking if the request includes a valid authorization token. Our server (shown later) extracts the full Authorization header — e.g., "Bearer token" — and stores it in context[:token]. In the resolver, we strip the "Bearer " prefix to recover the raw token and compare it against the expected value.

Ruby
def add_book(title:, author:)
  raw_token = context[:token].to_s.sub(/^Bearer\s+/, '')
  if raw_token != 'token'
    raise GraphQL::ExecutionError, 'You must be logged in'
  end
  new_book = { id: SecureRandom.uuid, title: title, author: author }
  BOOKS.push(new_book)
  new_book
end

This mutation extracts the raw token from the Authorization header value, then checks whether it matches 'token' (the value returned by our login mutation). If not, it raises a GraphQL::ExecutionError. We also use SecureRandom.uuid to generate a unique ID for each new book, ensuring there are no duplicate IDs when multiple books are added.

Here's the complete mutation type with both resolvers:

Ruby
class MutationType < GraphQL::Schema::Object
  field :login, String, null: true do
    argument :username, String, required: true
    argument :password, String, required: true
  end

  field :add_book, BookType, null: true do
    argument :title, String, required: true
    argument :author, String, required: true
  end

  def login(username:, password:)
    user = USERS.find { |u| u[:username] == username && u[:password] == password }
    if user.nil?
      raise GraphQL::ExecutionError, 'Invalid credentials'
    end
    'token'
  end

  def add_book(title:, author:)
    raw_token = context[:token].to_s.sub(/^Bearer\s+/, '')
    if raw_token != 'token'
      raise GraphQL::ExecutionError, 'You must be logged in'
    end
    new_book = { id: SecureRandom.uuid, title: title, author: author }
    BOOKS.push(new_book)
    new_book
  end
end

Setting up the server

Finally, we create our GraphQL schema and set up a Sinatra server to handle requests:

Ruby
# Define the schema
class AppSchema < GraphQL::Schema
  query QueryType
  mutation MutationType
end

# Set up Sinatra server
set :port, 4000

post '/graphql' do
  request_body = JSON.parse(request.body.read)
  query = request_body['query']
  variables = request_body['variables'] || {}
  
  # Extract authorization token from headers
  token = request.env['HTTP_AUTHORIZATION'] || ''
  
  result = AppSchema.execute(
    query,
    variables: variables,
    context: { token: token }
  )
  
  content_type :json
  result.to_json
end

puts '🚀 Server ready at http://localhost:4000/graphql'

The server extracts the full Authorization header (e.g., "Bearer token") from the request and passes it into the GraphQL execution context as context[:token], making it available to our resolvers. The resolver is then responsible for stripping the "Bearer " prefix and validating the raw token.

Testing the Implementation: Login

Let's test our implementation by making some queries to the server we've just set up. First, we call the login mutation to authorize our user.

Ruby
require 'net/http'
require 'json'
require 'uri'

URL = 'http://localhost:4000/graphql'

def login(username, password)
  query = <<~GRAPHQL
    mutation {
      login(username: "#{username}", password: "#{password}")
    }
  GRAPHQL

  uri = URI.parse(URL)
  http = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' })
  request.body = { query: query }.to_json

  response = http.request(request)
  data = JSON.parse(response.body)
  data['data']['login']
end

token = login('admin', 'admin')
puts "Token: #{token}"

This function sends a login request and retrieves the token.

Testing the Implementation: Query Books

After we have authorized, let's query our books from the server:

Ruby
def query_books(token)
  query = <<~GRAPHQL
    query {
      books {
        id
        title
        author
      }
    }
  GRAPHQL

  uri = URI.parse(URL)
  http = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Post.new(uri.path, {
    'Content-Type' => 'application/json',
    'Authorization' => "Bearer #{token}"
  })
  request.body = { query: query }.to_json

  response = http.request(request)
  JSON.parse(response.body)
end

token = login('admin', 'admin')
puts "Token: #{token}"

books_data = query_books(token)
puts "Books: #{JSON.pretty_generate(books_data)}"

This function queries the books with the provided token.

Testing the Implementation: Adding a New Book

Finally, let's try to add a new book to the server. Notice that we call the mutation as addBook — this is because graphql-ruby automatically converts our snake_case field name add_book into camelCase for the GraphQL schema.

Ruby
def add_book(token, title, author)
  mutation = <<~GRAPHQL
    mutation {
      addBook(title: "#{title}", author: "#{author}") {
        id
        title
        author
      }
    }
  GRAPHQL

  uri = URI.parse(URL)
  http = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Post.new(uri.path, {
    'Content-Type' => 'application/json',
    'Authorization' => "Bearer #{token}"
  })
  request.body = { query: mutation }.to_json

  response = http.request(request)
  JSON.parse(response.body)
end

token = login('admin', 'admin')
puts "Token: #{token}"

books_data = query_books(token)
puts "Books: #{JSON.pretty_generate(books_data)}"

new_book = add_book(token, '1984', 'George Orwell')
puts "New Book: #{JSON.pretty_generate(new_book)}"

This code logs in to get a token, queries the list of books, and attempts to add a new book.

Expected output:

JSON
Token: token
Books: {
  "data": {
    "books": [
      { "id": "1", "title": "The Hobbit", "author": "J.R.R. Tolkien" },
      { "id": "2", "title": "Harry Potter", "author": "J.K. Rowling" }
    ]
  }
}
New Book: {
  "data": {
    "addBook": {
      "id": "a3b2c1d4-e5f6-7890-abcd-ef1234567890",
      "title": "1984",
      "author": "George Orwell"
    }
  }
}

Note: The id value will be different each time you run this, since SecureRandom.uuid generates a unique identifier on every call.

Important Security Considerations

⚠️ The authentication implementation shown in this lesson is for educational purposes only and is NOT production-ready. Let's discuss what we did and why it's insecure:

What We Implemented (Insecure Demo Patterns)

  1. Plaintext passwords: We stored passwords as plain text in the USERS array
  2. Static token: The login mutation returns a hard-coded string 'token'
  3. No token validation: We check if the token equals 'token', which means anyone can authenticate without actually logging in
  4. No expiration: Tokens never expire
  5. Hard-coded user data: User credentials are stored directly in the code

Why This Is Insecure

  • Anyone who discovers the token ('token') can authenticate without valid credentials
  • Passwords are readable by anyone with access to the code or database
  • No user tracking: The token doesn't identify which specific user is making requests
  • Permanent access: Once someone has the token, they have access forever

Production-Ready Alternatives

For real applications, you should implement:

1. Password Hashing with bcrypt:

Ruby
require 'bcrypt'

# Storing a password
hashed_password = BCrypt::Password.create('admin')

# Verifying a password
BCrypt::Password.new(hashed_password) == 'admin'  # => true

2. JWT Tokens with Expiration:

Ruby
require 'jwt'

# Generate a token
payload = { user_id: 1, exp: Time.now.to_i + 3600 }  # Expires in 1 hour
secret = ENV['JWT_SECRET']
token = JWT.encode(payload, secret, 'HS256')

# Verify and decode a token
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })

3. Server-Side Token Store:

  • Use Redis or a database to store active sessions/tokens
  • Implement token revocation (logout)
  • Track token expiration server-side

4. Use Established Authentication Libraries:

  • Devise: Full-featured authentication solution
  • Rodauth: Modern, feature-complete authentication framework
  • Warden: Flexible authentication middleware

5. Environment Variables for Secrets:

Ruby
# Never hard-code credentials
JWT_SECRET = ENV['JWT_SECRET']

Key Takeaway

This lesson focused on understanding how authentication flows work in GraphQL - where to check tokens, how to pass context, and how to secure mutations. The implementation details were intentionally simplified to focus on these concepts. In your production applications, always use proper password hashing, cryptographically secure tokens, and established authentication libraries.

Lesson Summary

In this lesson, you learned how to add authentication to your GraphQL server using graphql-ruby and Sinatra. We:

  • Set up the GraphQL schema with basic authentication using graphql-ruby and Sinatra.
  • Implemented a login system to authenticate users.
  • Secured the add_book mutation to ensure only authenticated users can add books.

Next, you'll get hands-on practice with adding more secure queries and mutations. Great job on completing this lesson! Keep up the good work as you continue your journey in securing and optimizing GraphQL APIs.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal