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:
algorithmsis pinned. Left unset,jwt.verifyaccepts whatever the header claims, which is howalg: noneand 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:
- 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.
- Tokens expire while the socket lives. An hour-long connection can carry a fifteen-minute token. Decide explicitly: disconnect when the claim's
exppasses, 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. - 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
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?
2. Why pin the algorithms option when calling jwt.verify?
3. Why authenticate a WebSocket at connect time rather than on every event?
Flashcards
Question
Authn vs Authz
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
Answer
Always pass algorithms (e.g. RS256); the token's alg header is attacker-controlled and unpinned verifiers accept forgeries.
Question
WebSocket authentication
Click to reveal answer
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
Click to reveal answer
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[]withextensions.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