Free Ebook cover GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

New course

21 pages

Authentication Options: JWT, Sessions, and Context Construction

Capítulo 14

Estimated reading time: 0 minutes

+ Exercise

Why authentication in GraphQL feels different

In REST, authentication is often tied to endpoints: you protect certain routes and leave others public. In GraphQL, most requests hit a single endpoint, and the operation can traverse many fields that map to different business capabilities. That makes authentication and authorization decisions less about “which URL was called” and more about “who is calling, what operation they requested, and what data each field exposes.” Practically, this means your server must reliably identify the caller early (authentication) and then make that identity available to resolvers through a request-scoped context object (context construction). This chapter focuses on authentication options—JWT and sessions—and how to build a context that is safe, consistent, and efficient.

Authentication vs authorization (and where context fits)

Authentication answers “who is this?” (or “is this request associated with a valid user/service?”). Authorization answers “what are they allowed to do?” In GraphQL, you typically authenticate once per request and then authorize at one or more layers: at the operation level (e.g., only logged-in users can run mutations), at the field level (e.g., only admins can see a sensitive field), or at the data-source level (e.g., row-level permissions). The context object is the bridge: it carries the authenticated principal (user, service account, roles, tenant, scopes) and request metadata (request id, IP, user agent) so resolvers can make consistent decisions without re-parsing headers or re-validating tokens repeatedly.

JWT authentication: what it is and when it fits

A JSON Web Token (JWT) is a compact, signed token that encodes claims (like user id, tenant id, roles, scopes, expiration). The server verifies the signature and validates claims; if valid, the server trusts the claims and treats the request as authenticated. JWTs are popular for stateless APIs because the server does not need to store session state for each user. In GraphQL, JWTs work well for mobile apps, SPAs, and service-to-service calls where you want horizontal scaling without shared session storage.

JWT structure and key validation rules

A JWT has three parts: header, payload, signature. The important operational rules are: verify the signature with the correct algorithm; validate expiration (exp) and not-before (nbf) if present; validate issuer (iss) and audience (aud) to prevent token reuse across environments; and treat the token as untrusted until all checks pass. Avoid accepting “alg: none” or allowing algorithm confusion (e.g., accepting HS256 when you expect RS256). If you use asymmetric signing (RS256/ES256), fetch and cache public keys (JWKS) and rotate keys safely.

Where to store JWTs: header vs cookie

The most common pattern is an Authorization header: Authorization: Bearer <token>. This is straightforward for APIs and avoids CSRF concerns because browsers do not attach Authorization headers automatically. Another pattern is storing JWTs in cookies. Cookies can be convenient for browser apps, but you must handle CSRF (because cookies are automatically sent) and set secure attributes: HttpOnly, Secure, SameSite. If you store JWTs in localStorage, you reduce CSRF risk but increase XSS impact. For GraphQL, many teams prefer Authorization headers for SPAs and cookies for traditional web apps, but the “best” choice depends on your threat model and client constraints.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

JWT pros and cons in GraphQL

Pros: stateless verification, easy horizontal scaling, good for multiple clients, and can carry scopes/roles for quick checks. Cons: revocation is hard (tokens remain valid until exp), token size can bloat headers (especially if you add many claims), and rotating user permissions may not take effect until token refresh. In GraphQL, another subtle con is that resolvers might rely on claims that become stale (e.g., user role changed), so you should decide which claims can be trusted as-is and which should be re-checked against a database or authorization service.

Session-based authentication: what it is and when it fits

Session authentication typically uses a session id stored in a cookie. The server looks up session state in a store (memory, Redis, database) and reconstructs the user identity. Sessions are common for browser-first applications where cookie handling is natural and you want immediate revocation (delete session) and server-side control over session lifetime. In GraphQL, sessions can be a good fit for monolithic web apps, internal tools, or environments where you want centralized session management and easy logout across devices.

Session cookies and CSRF considerations

Because cookies are automatically included by the browser, session-based GraphQL endpoints are susceptible to CSRF if you allow state-changing operations without additional protection. GraphQL often uses POST requests, but POST alone does not prevent CSRF. Mitigations include SameSite cookies (Lax or Strict when possible), CSRF tokens (double-submit cookie or server-generated token), and checking Origin/Referer headers for browser requests. If you support file uploads or cross-site embedding, be explicit about which origins can call your endpoint.

Session pros and cons in GraphQL

Pros: easy revocation, smaller request headers, server-controlled session invalidation, and simpler permission updates (the server can read fresh roles each request). Cons: requires shared session storage for horizontal scaling, adds a lookup per request, and can complicate service-to-service calls where cookies are not natural. In GraphQL, sessions also require careful context construction to avoid repeated session store hits for each field; you want to load session once per request and reuse the result.

Context construction: the backbone of authenticated resolvers

Context is a per-request object created by your GraphQL server framework and passed to every resolver. It should contain: the authenticated principal (or null), request metadata, data-source clients configured for that principal (e.g., database connection with tenant scoping), and request-scoped utilities (logger, tracing span, loaders). The key design goal is to do authentication once, do it early, and make the result immutable or at least stable for the lifetime of the request.

What to include in context (and what to avoid)

Include: user (id, roles/scopes, tenant), auth details (token id, session id, auth method), requestId, ip, userAgent, and preconfigured clients like db, redis, services. Avoid: putting raw secrets (full JWT, refresh token) into context logs; storing large objects that increase memory per request; and adding mutable state that resolvers might accidentally change. If you need the raw token for downstream calls, store it in a dedicated field and ensure it is never logged.

Step-by-step: building context for JWT authentication

The following steps describe a typical request flow for JWT-based auth. The exact APIs differ by framework, but the logic is consistent.

  • Step 1: Extract the token from the Authorization header (or cookie if you use cookie-based JWT).
  • Step 2: If no token is present, set context.user = null and continue (public operations may exist).
  • Step 3: Verify the JWT signature using your configured algorithm and key source (static secret for HS256 or JWKS for RS256/ES256).
  • Step 4: Validate claims: exp, iss, aud, and any required custom claims like tenantId.
  • Step 5: Map claims to a principal object: { id, roles, scopes, tenantId }.
  • Step 6: Optionally load fresh user data (e.g., status, disabled flag) from your database or user service, especially if you need immediate revocation or account lockout.
  • Step 7: Construct context with a request id, logger, and any request-scoped helpers (like DataLoaders) that depend on the principal.
// Pseudocode: JWT context construction (Node-style, framework-agnostic)
async function buildContext({ req }) {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  const authHeader = req.headers['authorization'] || '';
  const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;

  let user = null;
  let auth = { method: 'anonymous' };

  if (token) {
    const decoded = await verifyJwt(token, {
      issuer: process.env.JWT_ISSUER,
      audience: process.env.JWT_AUDIENCE,
      jwksUrl: process.env.JWKS_URL,
      algorithms: ['RS256']
    });

    // Minimal principal from claims
    user = {
      id: decoded.sub,
      tenantId: decoded.tenantId,
      roles: decoded.roles || [],
      scopes: decoded.scopes || []
    };

    // Optional: enforce revocation/disabled checks
    const dbUser = await usersRepo.findById(user.id);
    if (!dbUser || dbUser.disabled) {
      throw new AuthError('Account is disabled');
    }

    auth = { method: 'jwt', tokenId: decoded.jti };
  }

  return {
    requestId,
    user,
    auth,
    logger: makeLogger({ requestId, userId: user?.id }),
    loaders: makeLoaders({ user }),
    services: makeServiceClients({ user })
  };
}

Step-by-step: building context for session authentication

Session context construction is similar, but the identity comes from a session store lookup. The main performance and correctness concerns are: validating the session cookie, preventing session fixation, and ensuring the session store is queried once per request.

  • Step 1: Read the session id from the cookie (or framework session middleware).
  • Step 2: If no session id exists, set context.user = null.
  • Step 3: Look up the session in your session store (e.g., Redis) and validate it (expiration, integrity).
  • Step 4: Extract user id and any session-bound metadata (tenant, auth time, MFA status).
  • Step 5: Load user record if needed (roles, disabled flag) and attach to context.
  • Step 6: Attach CSRF-related info if you enforce CSRF tokens for browser requests.
// Pseudocode: session context construction
async function buildContext({ req }) {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  const sessionId = readCookie(req, 'sid');

  let user = null;
  let auth = { method: 'anonymous' };

  if (sessionId) {
    const session = await sessionStore.get(sessionId);
    if (!session) throw new AuthError('Invalid session');

    const dbUser = await usersRepo.findById(session.userId);
    if (!dbUser || dbUser.disabled) throw new AuthError('Account is disabled');

    user = {
      id: dbUser.id,
      tenantId: session.tenantId,
      roles: dbUser.roles,
      scopes: dbUser.scopes
    };

    auth = { method: 'session', sessionId };
  }

  return {
    requestId,
    user,
    auth,
    logger: makeLogger({ requestId, userId: user?.id }),
    csrf: { required: isBrowserRequest(req) },
    loaders: makeLoaders({ user })
  };
}

Choosing between JWT and sessions (practical decision points)

Choose JWT when you need stateless scaling, multiple client types, and easy service-to-service authentication. Choose sessions when you want immediate revocation, strong server-side control, and a browser-centric experience with cookies. Many production systems support both: sessions for browser users and JWT for mobile/service clients. If you do support both, make the context builder detect the auth method deterministically (e.g., prefer Authorization header over cookie, or vice versa) and document the precedence to avoid confusing behavior.

Refresh tokens, rotation, and re-authentication

Access tokens should be short-lived. If you use JWT, you often pair it with refresh tokens to obtain new access tokens without forcing the user to log in again. Refresh tokens are sensitive and should be stored more securely than access tokens (often HttpOnly cookies for browser apps). Rotation means each refresh exchanges the old token for a new one and invalidates the previous refresh token, reducing replay risk. In a GraphQL setup, token refresh is commonly implemented as a dedicated mutation (or a separate endpoint) that returns a new access token, and possibly sets a new refresh cookie. For sessions, “refresh” is typically sliding expiration: the server extends session TTL on activity, but you should cap maximum lifetime and require re-authentication for high-risk actions.

Context-driven downstream calls: propagating identity safely

Resolvers often call other services. Your context should provide a consistent way to propagate identity: either forward the original Authorization header (if appropriate) or mint a service-to-service token with limited scope. Forwarding end-user tokens can be convenient but can also leak privileges if downstream services interpret scopes differently. A safer pattern is token exchange: validate the user token at the edge, then issue an internal token for downstream calls with explicit audience and minimal claims. If you do forward tokens, ensure you do not forward refresh tokens, and ensure logs and traces do not capture sensitive headers.

Handling anonymous access and partial authentication

Not every GraphQL operation needs authentication. Your context can represent anonymous callers with user = null. Some systems also support “partial authentication,” such as a user who is logged in but has not completed MFA, or a user with an email not verified. Model these states explicitly in context (e.g., authLevel or mfaVerified) so resolvers can enforce stronger requirements for sensitive mutations. Avoid sprinkling “if token exists” checks across resolvers; instead, centralize checks in small helper functions that read context consistently.

// Pseudocode: small helpers used by resolvers
function requireUser(ctx) {
  if (!ctx.user) throw new AuthError('Login required');
  return ctx.user;
}

function requireRole(ctx, role) {
  const user = requireUser(ctx);
  if (!user.roles.includes(role)) throw new ForbiddenError('Insufficient role');
  return user;
}

Security pitfalls specific to GraphQL authentication

Introspection and authentication

Introspection is not inherently insecure, but it can reveal your schema surface area. If you restrict introspection in production, decide whether it is disabled globally or only for anonymous users. If you disable it only for anonymous users, ensure your context builder is robust and cannot be bypassed by malformed headers. Also consider that developer tooling may rely on introspection; you might allow it for trusted roles or in non-production environments.

Leaking sensitive data through errors and logs

Authentication failures should not leak whether a user id exists or whether a token is “almost valid.” Use generic messages for clients and detailed messages only in server logs. Never log raw Authorization headers, cookies, refresh tokens, or full JWT payloads. If you need correlation, log a token id (jti) or a hash of the token. Ensure your context logger redacts sensitive fields by default.

Mixing auth methods unintentionally

If you support both cookies and Authorization headers, define precedence. Otherwise, a request might carry both (e.g., a browser with a session cookie plus a stale Authorization header from a debugging tool). Decide which one wins and enforce it. A common approach is: if Authorization header is present, use it; else fall back to session cookie. Document this behavior and add monitoring to detect unexpected combinations.

Subscriptions and long-lived connections

For GraphQL subscriptions (WebSocket or similar), authentication is often performed during connection initialization, not per message. That means your “context” for subscription resolvers can live for minutes or hours. If you rely on JWT expiration, you must decide what happens when the token expires mid-connection: disconnect, re-authenticate, or allow until reconnect. For sessions, you may need to re-check session validity periodically. Design this explicitly to avoid “zombie” authenticated connections.

Performance considerations in authentication and context building

Authentication runs on every request, so small inefficiencies add up. JWT verification with JWKS can be fast if keys are cached; without caching, it can become a network bottleneck. Session lookups require a store hit; use a low-latency store and keep session payload small. Avoid loading full user profiles in context if most operations only need user id and roles; you can load additional details lazily in resolvers using request-scoped loaders. Also ensure your context builder is resilient: timeouts for external calls (JWKS, session store), clear fallback behavior, and metrics for auth latency and failure rates.

Practical patterns: implementing login and logout flows

JWT login flow (typical)

A common pattern is a login mutation that validates credentials and returns an access token (and optionally sets a refresh token cookie). Step-by-step: validate credentials; check account status; issue short-lived access token with minimal claims; issue refresh token with rotation; return token(s) and basic user info. Logout typically revokes refresh tokens (server-side list) or rotates them to invalid, because access tokens may remain valid until expiration.

Session login flow (typical)

For sessions, login creates a server-side session record and sets a session cookie. Step-by-step: validate credentials; create session with TTL and metadata (tenant, auth time, MFA state); set cookie with Secure and HttpOnly; return basic user info. Logout deletes the session record and clears the cookie. If you support “logout everywhere,” delete all sessions for that user id.

Testing and debugging authentication in GraphQL

Test authentication at three levels: context construction unit tests (token validation, session lookup, precedence rules), resolver integration tests (anonymous vs authenticated behavior), and end-to-end tests (login, refresh, logout). For debugging, add structured logs that include request id, auth method, user id (if present), and failure reason category (expired token, invalid signature, missing session) without including secrets. In local development, provide a safe way to generate test tokens or mock sessions, but ensure this is disabled in production builds.

Now answer the exercise about the content:

In a GraphQL server, what is the main reason to build a request-scoped context after authenticating the caller?

You are right! Congratulations, now go to the next page

You missed! Try again.

GraphQL requests can touch many fields, so you authenticate once per request and place the resulting identity and metadata in a request-scoped context. Resolvers then use this shared context for consistent authorization without repeatedly parsing headers or re-checking tokens.

Next chapter

Authorization Design: Role-Based and Field-Level Permissions

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.