Authorization in GraphQL: the field-level nightmare
GraphQL gives clients a lot of power. The authorization story for that power is, charitably, a mess you have to solve yourself.
GraphQL's authorization story is one of the most underestimated sources of production bugs I've encountered. Not because it's impossible to get right, but because the spec leaves it entirely to you — and the pit is deep enough that I've watched otherwise careful developers fall straight in.
What GraphQL leaves on the floor
With REST, authorization has a natural seam: the endpoint. You protect GET /users/{id}/salary differently than GET /users/{id}/name. The route is the resource. Middleware sits in front of it. It's not perfect, but the model is obvious.
GraphQL blows that model up. A single POST /graphql endpoint accepts arbitrary query shapes. The client can ask for user { name } or user { name salary ssn bankAccount { routingNumber } } and both requests hit the same endpoint. Middleware that guards the route can't distinguish between them. You have to authorize at the field level, inside the resolver graph, and that is a fundamentally different — and harder — problem.
I ran into this acutely on a healthcare project a few years back. We were building an internal portal on top of a Laravel + Lighthouse stack. Clinicians, billing staff, and admin users all hit the same GraphQL API. A clinician should see vitals and notes. Billing should see charges and insurance info. Neither should see the other's data. Getting that right took real architecture, not just a middleware flag.
The failure mode nobody warns you about
The most dangerous pattern I see is what I call "top-level gate, open interior." Someone protects the query entry point but leaves the fields wide open:
// In your Lighthouse schema
type Query {
patient(id: ID! @eq): Patient @find @guard
}
type Patient {
id: ID!
name: String!
dateOfBirth: String!
ssn: String! # oops
diagnoses: [Diagnosis!]!
billingInfo: BillingRecord # also oops
}
The @guard directive confirms the user is authenticated before the patient query resolves. But once you're past that gate, every field on Patient is fair game. An authenticated billing clerk can query patient { ssn diagnoses { icdCode description } } and get it all. You've authenticated, but you haven't authorized.
This isn't hypothetical. I've audited APIs where this was exactly the state of production code. The developers knew auth was "handled" because they had @guard on their queries. They didn't realize that was the wrong layer.
What field-level authorization actually looks like
In Lighthouse, the cleanest tool is a custom field middleware directive. Here's a pattern I've used that holds up:
// In your schema
type Patient {
id: ID!
name: String!
ssn: String! @canSeeField(ability: 'view-ssn')
diagnoses: [Diagnosis!]! @canSeeField(ability: 'view-diagnoses')
billingInfo: BillingRecord @canSeeField(ability: 'view-billing')
}
<?php
namespace App\GraphQL\Directives;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use Illuminate\Auth\Access\AuthorizationException;
class CanSeeFieldDirective extends BaseDirective implements FieldMiddleware
{
public static function definition(): string
{
return /** @lang GraphQL */ '
directive @canSeeField(ability: String!) on FIELD_DEFINITION
';
}
public function handleField(FieldValue $fieldValue, Closure $next): FieldValue
{
$fieldValue->wrapResolver(function ($resolver) {
return function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($resolver) {
$user = $context->user();
if (!$user || $user->cannot($this->directiveArgValue('ability'), $root)) {
// Return null instead of throwing — less information leakage
return null;
}
return $resolver($root, $args, $context, $resolveInfo);
};
});
return $next($fieldValue);
}
}
The decision to return null versus throw an authorization exception is not trivial. Throwing means the entire query errors out — which leaks information about field existence. Returning null is quieter but means the client has to know that null on a non-nullable field means "you don't have access", not "there's no data". I generally make restricted fields nullable in the schema and document the convention. It's a tradeoff, not a right answer.
The N+1 problem meets the auth problem
Here's where it gets worse. Field-level auth often needs to check the parent object to make a decision. Is this user allowed to see this patient's SSN? That depends on whether the user has a care relationship with that specific patient, or whether they're in the same billing group, or whatever your domain rules are.
Now every resolved field is potentially running a policy check that hits the database. If you're resolving a list of 50 patients and checking authorization on ssn for each one, you've just added 50 extra queries. The N+1 problem was already lurking in your resolvers. Auth checks make it worse.
The mitigation I've reached for is eager-loading authorization state onto the model before the resolver runs:
// In the Patient model
class Patient extends Model
{
// Cache auth decisions per-request on the model instance
public function authorizedFieldsForUser(User $user): array
{
if (!isset($this->_authorizedFields)) {
$this->_authorizedFields = PatientFieldPolicy::resolveFor($user, $this);
}
return $this->_authorizedFields;
}
}
// PatientFieldPolicy::resolveFor does one query, returns an array of allowed fields
// Then your directive checks $root->authorizedFieldsForUser($user) instead of
// calling $user->can() per field
It's more plumbing, but it collapses the auth checks from N queries to one per model instance. On a list resolver, you can batch this further with a DataLoader-style pattern. The point is: you have to think about it, because GraphQL won't do it for you.
The introspection leak you might be ignoring
GraphQL's introspection system will happily tell any client about every field in your schema, including the ones with auth directives on them. By default, an unauthenticated user can run:
{
__type(name: "Patient") {
fields {
name
}
}
}
...and get back ssn, bankAccount, diagnoses — the full list. This is a schema design and information disclosure question. For internal APIs behind a login wall I'm less worried. For anything with public schema access, you should disable introspection in production or at minimum return filtered introspection results based on the current user's role.
In Lighthouse:
// config/lighthouse.php
'security' => [
'max_query_complexity' => 200,
'max_query_depth' => 15,
'disable_introspection' => \Nuwave\Lighthouse\Security\DisableIntrospection::class,
],
Or you can use a conditional that only disables it for unauthenticated users, which is a reasonable middle ground for developer experience.
When I'd reach for GraphQL anyway
Despite all this, I still ship GraphQL for the right use cases. Rich, interconnected data models where clients have legitimate reasons to fetch different shapes — that's where GraphQL earns its keep. The healthcare portal I mentioned is actually a good example: clinicians, billing, and admin users all work with Patient data, just different slices of it. GraphQL let us build one API surface instead of three REST APIs. The auth overhead was real but contained.
I'd also reach for it when I control both ends — internal tools, admin dashboards, first-party mobile apps. The security surface is smaller when I'm not exposing the API to arbitrary third-party consumers.
When I wouldn't
Public-facing APIs where I don't control the client. The attack surface of an arbitrary query language pointed at your data is just bigger than a set of REST endpoints, and the authorization tools aren't mature enough yet to make that feel safe without a lot of custom investment.
Also: small CRUD apps. If you've got 10 resources and straightforward access rules, GraphQL's complexity is a net negative. REST with proper route-level middleware is simpler to reason about and easier to audit. I've seen developers reach for GraphQL because it felt modern, then spend weeks solving auth problems that wouldn't exist in REST. Don't do that.
The bottom line
GraphQL ships you a powerful query engine and waves goodbye. The authorization architecture is yours to build, and if you approach it the way you'd approach REST — guard the entry point and assume the interior is safe — you will have a security problem in production. Think in resolvers, think in fields, and be honest about how much custom infrastructure that takes. It's doable. It's just not free.
Need help shipping something like this? Get in touch.