An authorization check can run, deny access, and still allow the protected operation to happen. That was exactly the behavior found in graphql-ruby when an application combined the Execution::Next runner with resolvers whose authorized? hook raised GraphQL::UnauthorizedError. The error was caught, but the internal state was changed to represent granted authorization. The framework then called resolve as usual.
The maintainer advisory, GHSA-j7xr-4g94-r9h3, rates the flaw High and does not associate it with a CVE. GitHub Security Lab's technical analysis uses the identifier GHSL-2026-152. The confirmed affected range is graphql-ruby 2.5.23 through 2.6.5, and the fix was released in 2.6.6 on July 21, 2026. GHSL published its detailed technical disclosure on August 8.

The exact scope of the flaw
This was not a generic bypass of every GraphQL authorization mechanism. The application had to use GraphQL::Execution::Next, and the field had to go through GraphQL::Schema::Resolver or a subclass such as Mutation, RelayClassicMutation, or Subscription. Inside that resolver, authorized? had to deny access by raising GraphQL::UnauthorizedError. According to the advisory, other authorization mechanisms were still applied correctly.
- Affected versions: graphql-ruby >= 2.5.23 and <= 2.6.5
- Patched version: graphql-ruby 2.6.6 or later
- Required configuration: use GraphQL::Execution::Next
- Vulnerable path: authorized? raises GraphQL::UnauthorizedError in a Resolver or subclass
- Possible impact: unauthorized reads, data changes, external calls, and privilege escalation
An exception turned into authorization
In the vulnerable Resolver#call path, the normal return from authorized? was split into a boolean and an alternate value. When the hook raised GraphQL::UnauthorizedError, the rescue block called schema.unauthorized_object, whose default behavior returns nil, but set the authorization boolean to true. The next condition saw true and called call_resolve. Business code had no indication that authorization had failed.
class DeleteAccount < GraphQL::Schema::Mutation
type Boolean, null: false
def authorized?(account_id:)
raise GraphQL::UnauthorizedError unless context[:current_user]&.admin?
true
end
def resolve(account_id:)
Account.find(account_id).destroy!
true
end
end
class ApiSchema < GraphQL::Schema
use GraphQL::Execution::Next
endThe example is deliberately small, but real impact grows with the resolver. A query can return restricted data. A mutation can write to the database, delete an object, or call an external provider. A subscription can open an event stream the user should not observe. Even when the final response looks empty, a side effect inside resolve may already have happened.

How to confirm exposure without attacking production
Triage starts in the code and lockfile. First, we confirm the gem version actually resolved at runtime, not only the constraint written in the Gemfile. Then we look for Execution::Next activation, execute_next calls, and authorized? implementations that raise GraphQL::UnauthorizedError. That combination sharply narrows the review before any dynamic request is sent.
bundle exec ruby -e 'require "graphql"; puts GraphQL::VERSION'
rg -n 'GraphQL::Execution::Next|execute_next' app lib config
rg -n 'def authorized\?|GraphQL::UnauthorizedError' app libIf the combination exists, we reproduce it in a test suite or isolated environment with an explicitly unauthorized user. Evidence must capture three facts: the hook denied access, the field did not return the data, and the side effect did not happen. Checking only the HTTP status is insufficient in GraphQL, where field errors commonly travel inside a 200 response.
The regression test that closes the gap
The best test does not rely only on response content. It observes state before and after the mutation. This way, a future change to the error format cannot hide a regression that executes resolve again. The example below is intentionally generic and should be adapted to the project's schema names and factories.
it "does not execute a denied mutation" do
account = create(:account)
denied_user = create(:user, admin: false)
expect {
ApiSchema.execute_next(
"mutation($id: ID!) { deleteAccount(accountId: $id) }",
variables: { id: account.id },
context: { current_user: denied_user }
)
}.not_to change(Account, :count)
expect(Account.exists?(account.id)).to be(true)
endFor queries, the same idea applies with resolver tracing and an assertion that data is absent. For external integrations, replace the real call with a spy and require that it is not invoked. The goal is to test the security property, a negative decision stops execution, instead of testing only how the response looks.
Fix and containment
The recommended fix is upgrading graphql-ruby to 2.6.6 or later. The project has already published newer releases, but 2.6.6 is the first fixed floor confirmed by the advisory. As temporary containment, GHSL notes that a custom unauthorized_object implementation that also raises an exception interrupts the vulnerable path. This reduces exposure, but it should not replace the update and regression tests.
- Update the gem and confirm GraphQL::VERSION in the deployed process
- Run the authorization suite with the same runner used in production
- Review mutations that write, delete, send messages, or call external APIs first
- Search logs for sensitive operations performed by identities without the required role
- If abuse is suspected, treat the update as containment and preserve evidence for incident response
The lesson for a GraphQL pentest
A shallow test confirms that the API produced an authorization error. A useful pentest checks whether execution actually stopped. In GraphQL, that means observing resolvers, state changes, queues, webhooks, and external calls in addition to the JSON returned to the client. GHSA-j7xr-4g94-r9h3 shows why access control must be tested as execution flow, not as an error message.