Skip to content
intermediate Phase 10 · Authentication and Authorization for GraphQL APIs

Resolving Authentication into Context

Verifying tokens at the transport layer and exposing the caller to every resolver.

45m
1 problems
Topic Progress 0%

Verify the Token Once, at the Door

Verify the Token Once, at the Door

Every resolver in your schema will want to know who is calling. The wrong way is to let each one dig into the header and run jwt.verify itself: fifty verifications of the same token, fifty places where an algorithm or expiry check can drift, and any resolver that forgets is silently anonymous.

The right place is the context factory, which runs once per request before execution begins:

async function buildContext({ req }) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return { viewer: null };             // anonymous is a valid caller

  try {
    const claims = await jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],                      // pinned: never trust the token's own alg field
      issuer: 'https://auth.example.com',
      audience: 'graphql-api',
    });
    return {
      viewer: {
        id: claims.sub,
        roles: Array.isArray(claims.roles) ? claims.roles : [],
        tenantId: claims.ten,
      },
    };
  } catch (err) {
    throw new AuthenticationError('Invalid or expired token');
  }
}

Lines worth defending in review:

  • algorithms is pinned. Left unset, jwt.verify accepts whatever the header claims, which is how alg: none and RS/HS confusion attacks work.
  • Claims are read only after verification. Untokenized header contents never reach context — a resolver that trusts req.headers['x-user-id'] is trusting the network.
  • One failure path. Malformed, expired, wrong audience: same message, same status, nothing leaked about which accounts exist.

Resolvers then read ctx.viewer synchronously — no crypto in the hot path, no drift. This answers who the caller is. What they may read or mutate is a separate decision owned by authorization, starting in role-based field access. Keeping that boundary crisp is what lets you swap identity providers without touching a single field check.

401 at the Transport, Errors in the Body

401 at the Transport, Errors in the Body

GraphQL happily returns HTTP 200 with a body full of errors, which is exactly why authentication failures need to be distinguished deliberately:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token",
                  error_description="The access token expired"
Content-Type: application/json

{ "errors": [{ "message": "Authentication required" }] }

The WWW-Authenticate header is the contract that lets a client decide without parsing the body: clear the session and redirect to login, versus keep the session and fix the query. A 200 that says "unauthenticated" in errors[] forces every client to pattern-match prose, and any client that forgets will render "Invalid token" as a form-validation message.

Draw the line at the request boundary:

Situation Signal
Missing / malformed / expired token on a protected endpoint 401 + WWW-Authenticate, request never reaches execution
Authenticated (or anonymous-allowed) caller, query fails HTTP 200 + errors[] with extensions.code
Caller lacks permission for a field during execution errors[] with extensions.code: 'FORBIDDEN' (see error extensions)

Once execution starts, authentication is already settled — a resolver throwing AuthenticationError mid-execution produces a partially completed response, and half a response is a confusing response. Throw during context construction instead, before the first resolver runs.

Two details that production surfaces quickly: keep the message identical for expired and malformed tokens (differences are an oracle), and make sure your HTTP layer does not rewrite a 401 into a 200 with an HTML error page — test the status code, not just the body.

Authenticating WebSocket Connections

Authenticating WebSocket Connections

A WebSocket has no Authorization header per event. Headers exist once, during the HTTP upgrade; everything after that travels inside frames the server already accepted. Authentication therefore moves to connection time, and the claims must be carried on the connection context:

// graphql-ws style
useServer({
  schema,
  onConnect: async (ctx) => {
    const token = ctx.connectionParams?.Authorization ?? ctx.connectionParams?.token;
    if (!token) throw new Error('Unauthorized');   // refuse the socket outright
    ctx.extra.user = await verifyToken(token);     // parsed once per connection
  },
  context: (ctx) => ({
    viewer: ctx.extra.user,                        // every operation reuses it
    loaders: createLoaders(db, ctx.extra.user),
  }),
});

Three consequences to design for:

  1. Refusing happens at connect, not per event. A socket that never authenticates never opens, so an unauthenticated client cannot subscribe to a channel and wait for leaks. Event-time checks still apply for authorization — connection proves identity, not permission.
  2. Tokens expire while the socket lives. An hour-long connection can carry a fifteen-minute token. Decide explicitly: disconnect when the claim's exp passes, re-verify on a schedule, or keep the socket and re-check authorization on every event. Silently trusting the connect-time claim for the connection's whole life is the common accident. Protocol-level reconnect behavior is covered in subscription lifecycle.
  3. A fresh loader scope per event keeps long-lived sockets from serving one event's cached rows to the next — see request-scoped loaders.

One asymmetry worth stating in an interview: HTTP requests re-authenticate every time, so a revoked token stops working on the next request; a WebSocket authenticates once, so revocation needs an explicit policy (disconnect on revocation event, or per-event permission checks that fail when the token is no longer valid).

Practice Problems

0 / 1 solved
Wire JWT Verification into Context
Authentication Pipeline

Write a context factory that accepts an optional Bearer token, verifies it with a pinned algorithm, and returns viewer claims; anonymous requests must still execute public fields. A bad token must fail the request before any resolver runs.

Example:

Input: Authorization: Bearer <expired RS256 JWT with sub=usr_9, roles=["user"], ten=acme>

Output: Request rejected with 401 before execution; no resolver is invoked

jwt.verify throws on expiry, the factory converts that to an authentication failure during context construction, and execution never starts — so no partial response and no resolver ever observes an unverified caller.

Optimal Solution — O(1) time, O(1) space

Build context synchronously around a single verify call, normalize claims into a viewer object, and throw an authentication error from the factory for invalid tokens

async function buildContext({ req }) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return { viewer: null };            // public fields stay reachable

  let claims;
  try {
    claims = await jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'https://auth.example.com',
      audience: 'graphql-api',
    });
  } catch (e) {
    throw new AuthenticationError('Invalid or expired token'); // -> 401, no execution
  }

  return {
    viewer: {
      id: claims.sub,
      roles: Array.isArray(claims.roles) ? claims.roles : [],
      tenantId: claims.ten,
    },
  };
}

const server = new ApolloServer({ schema, context: buildContext });

// resolver side: trust the object, not the header
Query: {
  me: (_, __, ctx) => ctx.viewer,
  auditLog: (_, __, ctx) => {
    if (!ctx.viewer) throw new ForbiddenError('Authentication required');
    return auditService.list();
  },
},

Edge Cases:

  • No Authorization header: return viewer null and let field-level checks decide
  • Token signed with a different algorithm than pinned: reject, never fall back
  • Roles claim missing or not an array: normalize to [] so role checks fail closed

Quiz

1. Where should a JWT be verified in a GraphQL server?

Question 1 options

2. Why pin the algorithms option when calling jwt.verify?

Question 2 options

3. Why authenticate a WebSocket at connect time rather than on every event?

Question 3 options

Flashcards

Question

Authn vs Authz

Answer

Authentication says who the caller is (token → claims in context); authorization decides what that caller may read or mutate.

Question

401 vs GraphQL errors

Answer

Bad or missing token → HTTP 401 with WWW-Authenticate before execution; failures during execution → 200 with errors[] and extensions.code.

Question

Pin jwt algorithms

Answer

Always pass algorithms (e.g. RS256); the token's alg header is attacker-controlled and unpinned verifiers accept forgeries.

Question

WebSocket authentication

Answer

Verify at connect (connection_init or upgrade), store claims on the connection, and define expiry/revocation behavior for long-lived sockets.

Question

Context contract

Answer

Built once per request, holds viewer + tenant + loaders; resolvers read it synchronously and never re-verify.

Revision Notes

Key Takeaways

  • 1. Verify the token once in the context factory with pinned algorithms; resolvers consume claims, never headers
  • 2. Reject at the transport with 401 + WWW-Authenticate; use errors[] with extensions.code once execution begins
  • 3. Sockets authenticate at connect and need an explicit expiry and revocation policy

Interview Tips

  • • Walk the request path: header → verify → claims → context → resolver, and say where each failure surfaces
  • • Ask whether anonymous callers are allowed before writing the check — "is this field public?" is the question interviewers wait for

Cheat Sheet

Auth into Context Cheat Sheet

Verify once, per request

context: async ({ req }) => {
  const token = (req.headers.authorization || '').replace('Bearer ', '');
  if (!token) return { viewer: null };
  const claims = await jwt.verify(token, PUB_KEY, {
    algorithms: ['RS256'], issuer: ISS, audience: AUD,
  });
  return { viewer: { id: claims.sub, roles: claims.roles, tenantId: claims.ten } };
}

Status rules

  • Bad/missing token → 401 + WWW-Authenticate (before execution)
  • Execution failure → 200 + errors[] with extensions.code

Never: verify in resolvers · read unverified claims · trust custom identity headers · leave algorithms unset

WebSockets: verify in onConnect/connection_init → store on connection → per-event authorization → explicit disconnect when exp passes

Continue Learning