Keycloak + GraphQL: Securing Apollo Server APIs

Guilliano Molaire Guilliano Molaire Updated June 8, 2026 8 min read

Last updated: March 2026

Introduction

GraphQL APIs present unique authentication challenges compared to REST. Every request hits a single endpoint, authorization requirements vary per field, and subscriptions need persistent authenticated connections. Apollo Server v4, the most widely used GraphQL server for Node.js, provides the hooks to handle all of this cleanly.

This guide shows how to integrate Keycloak with Apollo Server v4 for JWT-based authentication, directive-based authorization (@auth, @hasRole), resolver-level access control, and authenticated WebSocket subscriptions. We use Keycloak as the identity provider and assume you have a Keycloak realm configured with OIDC clients.

If you need to set up Keycloak locally, our Keycloak Config Generator and Docker Compose Generator can get you started quickly.

Project Setup

Initialize a new Node.js project with the required dependencies:

mkdir apollo-keycloak && cd apollo-keycloak
npm init -y
npm install @apollo/server graphql jsonwebtoken jwks-rsa 
  @graphql-tools/schema @graphql-tools/utils graphql-ws ws 
  graphql-tag express cors
npm install -D typescript @types/node @types/express 
  @types/jsonwebtoken @types/cors tsx

JWT Verification Middleware

First, create a module that verifies Keycloak JWTs using the realm’s JWKS endpoint:

// src/auth.ts
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

const KEYCLOAK_URL = process.env.KEYCLOAK_URL
  || "http://localhost:8080";
const REALM = process.env.KEYCLOAK_REALM || "graphql-demo";

const client = jwksClient({
  jwksUri:
    `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs`,
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 600_000, // 10 minutes
});

function getSigningKey(
  header: jwt.JwtHeader,
  callback: jwt.SigningKeyCallback,
): void {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    const signingKey = key?.getPublicKey();
    callback(null, signingKey);
  });
}

export interface KeycloakTokenClaims {
  sub: string;
  email?: string;
  preferred_username?: string;
  realm_access?: { roles: string[] };
  resource_access?: Record<string, { roles: string[] }>;
  scope?: string;
}

export async function verifyToken(
  token: string,
): Promise<KeycloakTokenClaims | null> {
  return new Promise((resolve) => {
    jwt.verify(
      token,
      getSigningKey,
      {
        algorithms: ["RS256"],
        issuer: `${KEYCLOAK_URL}/realms/${REALM}`,
      },
      (err, decoded) => {
        if (err) {
          console.error("Token verification failed:", err.message);
          resolve(null);
        } else {
          resolve(decoded as KeycloakTokenClaims);
        }
      },
    );
  });
}

You can test your tokens with the JWT Token Analyzer to verify claims before integrating them into your application.

Apollo Server Context

Apollo Server v4 uses a context function to extract authentication from each request. We parse the Authorization header, verify the JWT, and attach the user to the context:

// src/context.ts
import { type KeycloakTokenClaims, verifyToken } from "./auth";

export interface GraphQLContext {
  user: KeycloakTokenClaims | null;
}

export async function createContext({
  req,
}: { req: { headers: Record<string, string | undefined> } }):
  Promise<GraphQLContext> {
  const authHeader = req.headers.authorization || "";
  const token = authHeader.startsWith("Bearer ")
    ? authHeader.slice(7)
    : null;

  if (!token) {
    return { user: null };
  }

  const user = await verifyToken(token);
  return { user };
}

Schema with Auth Directives

Define custom directives for declarative authorization in your schema:

// src/schema.ts
import gql from "graphql-tag";

export const typeDefs = gql`
  # Custom auth directives
  directive @auth on FIELD_DEFINITION | OBJECT
  directive @hasRole(roles: [String!]!) on FIELD_DEFINITION | OBJECT

  type User {
    id: ID!
    email: String!
    username: String!
    roles: [String!]!
  }

  type Project {
    id: ID!
    name: String!
    description: String
    owner: User!
    # Only project admins can see billing info
    billingInfo: BillingInfo @hasRole(roles: ["project-admin"])
  }

  type BillingInfo {
    plan: String!
    monthlyAmount: Float!
  }

  type Query {
    # Public - no auth needed
    health: String!

    # Requires authentication
    me: User! @auth

    # Requires specific roles
    projects: [Project!]! @auth
    adminDashboard: AdminStats! @hasRole(roles: ["admin"])
  }

  type Mutation {
    createProject(name: String!, description: String): Project!
      @auth
    deleteProject(id: ID!): Boolean!
      @hasRole(roles: ["admin", "project-admin"])
  }

  type Subscription {
    projectUpdated(projectId: ID!): Project! @auth
  }

  type AdminStats {
    totalUsers: Int!
    totalProjects: Int!
    activeSubscriptions: Int!
  }
`;

Implementing Auth Directive Transformers

Apollo Server v4 uses schema transformers rather than the legacy SchemaDirectiveVisitor. Here is how to implement the @auth and @hasRole directives:

// src/directives.ts
import { getDirective, MapperKind, mapSchema }
  from "@graphql-tools/utils";
import { defaultFieldResolver, type GraphQLSchema }
  from "graphql";
import type { GraphQLContext } from "./context";

export function authDirectiveTransformer(
  schema: GraphQLSchema,
): GraphQLSchema {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(
        schema, fieldConfig, "auth",
      )?.[0];

      if (authDirective) {
        const { resolve = defaultFieldResolver } = fieldConfig;
        fieldConfig.resolve = async function (
          source, args, context: GraphQLContext, info,
        ) {
          if (!context.user) {
            throw new Error(
              "Authentication required. Please provide a valid "
              + "Keycloak access token.",
            );
          }
          return resolve(source, args, context, info);
        };
      }
      return fieldConfig;
    },
  });
}

export function hasRoleDirectiveTransformer(
  schema: GraphQLSchema,
): GraphQLSchema {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(
        schema, fieldConfig, "hasRole",
      )?.[0];

      if (directive) {
        const requiredRoles: string[] = directive.roles;
        const { resolve = defaultFieldResolver } = fieldConfig;

        fieldConfig.resolve = async function (
          source, args, context: GraphQLContext, info,
        ) {
          if (!context.user) {
            throw new Error("Authentication required.");
          }

          const userRoles =
            context.user.realm_access?.roles || [];
          const hasRequired = requiredRoles.some(
            (role) => userRoles.includes(role),
          );

          if (!hasRequired) {
            throw new Error(
              `Forbidden. Required roles: ${requiredRoles.join(", ")}. `
              + `Your roles: ${userRoles.join(", ")}`,
            );
          }

          return resolve(source, args, context, info);
        };
      }
      return fieldConfig;
    },
  });
}

Resolvers

// src/resolvers.ts
import type { GraphQLContext } from "./context";

// In-memory data for demonstration
const projects = [
  {
    id: "1", name: "Auth Service", description: "SSO integration",
    ownerId: "user-1",
    billingInfo: { plan: "Pro", monthlyAmount: 99.0 },
  },
  {
    id: "2", name: "API Gateway", description: "Kong + Keycloak",
    ownerId: "user-2",
    billingInfo: { plan: "Enterprise", monthlyAmount: 499.0 },
  },
];

export const resolvers = {
  Query: {
    health: () => "OK",

    me: (_: unknown, __: unknown, ctx: GraphQLContext) => {
      // Auth directive already verified the user exists
      return {
        id: ctx.user!.sub,
        email: ctx.user!.email || "",
        username: ctx.user!.preferred_username || "",
        roles: ctx.user!.realm_access?.roles || [],
      };
    },

    projects: () => projects,

    adminDashboard: () => ({
      totalUsers: 1250,
      totalProjects: 340,
      activeSubscriptions: 89,
    }),
  },

  Mutation: {
    createProject: (
      _: unknown,
      args: { name: string; description?: string },
      ctx: GraphQLContext,
    ) => {
      const newProject = {
        id: String(projects.length + 1),
        name: args.name,
        description: args.description || null,
        ownerId: ctx.user!.sub,
        billingInfo: { plan: "Free", monthlyAmount: 0 },
      };
      projects.push(newProject);
      return newProject;
    },

    deleteProject: (
      _: unknown,
      args: { id: string },
    ) => {
      const index = projects.findIndex((p) => p.id === args.id);
      if (index === -1) return false;
      projects.splice(index, 1);
      return true;
    },
  },

  Project: {
    owner: (project: { ownerId: string }) => ({
      id: project.ownerId,
      email: `${project.ownerId}@example.com`,
      username: project.ownerId,
      roles: [],
    }),
  },
};

Server Assembly

Bring everything together in the main server file:

// src/server.ts
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { makeExecutableSchema } from "@graphql-tools/schema";
import cors from "cors";
import express from "express";
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import {
  type GraphQLContext,
  createContext,
} from "./context";
import {
  authDirectiveTransformer,
  hasRoleDirectiveTransformer,
} from "./directives";
import { resolvers } from "./resolvers";
import { typeDefs } from "./schema";
import { verifyToken } from "./auth";

async function startServer(): Promise<void> {
  const app = express();
  const httpServer = createServer(app);

  // Build schema with directive transformers
  let schema = makeExecutableSchema({ typeDefs, resolvers });
  schema = authDirectiveTransformer(schema);
  schema = hasRoleDirectiveTransformer(schema);

  // WebSocket server for subscriptions
  const wsServer = new WebSocketServer({
    server: httpServer,
    path: "/graphql",
  });

  // Authenticate WebSocket connections
  const wsServerCleanup = useServer(
    {
      schema,
      context: async (ctx) => {
        // Extract token from connection params
        const token =
          ctx.connectionParams?.authorization as string
          || ctx.connectionParams?.Authorization as string
          || "";
        const bearerToken = token.startsWith("Bearer ")
          ? token.slice(7)
          : token;

        if (!bearerToken) {
          throw new Error(
            "Authentication required for subscriptions",
          );
        }

        const user = await verifyToken(bearerToken);
        if (!user) {
          throw new Error("Invalid token");
        }

        return { user } satisfies GraphQLContext;
      },
    },
    wsServer,
  );

  const server = new ApolloServer<GraphQLContext>({
    schema,
    plugins: [
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await wsServerCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use(
    "/graphql",
    cors<cors.CorsRequest>(),
    express.json(),
    expressMiddleware(server, { context: createContext }),
  );

  const PORT = process.env.PORT || 4000;
  httpServer.listen(PORT, () => {
    console.log(`GraphQL server ready at http://localhost:${PORT}/graphql`);
    console.log(`Subscriptions at ws://localhost:${PORT}/graphql`);
  });
}

startServer();

Run the server:

npx tsx src/server.ts

Keycloak Realm Configuration

Create the Realm and Client

  1. Log in to Keycloak admin at http://localhost:8080/admin.
  2. Create a realm named graphql-demo.
  3. Create a client:
Setting Value
Client ID apollo-app
Client Protocol openid-connect
Client Authentication OFF (public client for SPAs)
Valid Redirect URIs http://localhost:3000/*
Web Origins http://localhost:3000
  1. Create realm roles: admin, project-admin, user.
  2. Create a test user and assign the user role.

For production, configure proper redirect URIs and enable PKCE.

Token Mapper for GraphQL

Add a custom client scope that includes project-related claims if needed:

  1. Go to Client Scopes > Create.
  2. Name it graphql-api.
  3. Add a User Attribute mapper or Hardcoded claim mapper for any custom claims your GraphQL resolvers need.
  4. Assign this scope to the apollo-app client.

Testing with curl

Get a Token

TOKEN=$(curl -s -X POST 
  "http://localhost:8080/realms/graphql-demo/protocol/openid-connect/token" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "grant_type=password" 
  -d "client_id=apollo-app" 
  -d "username=testuser" 
  -d "password=testpassword" 
  -d "scope=openid" | jq -r '.access_token')

Query Without Auth (Public Endpoint)

curl -s -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -d '{"query": "{ health }"}'
# {"data":{"health":"OK"}}

Query With Auth

curl -s -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer ${TOKEN}" 
  -d '{"query": "{ me { id email username roles } }"}'

Query Requiring Admin Role

curl -s -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer ${TOKEN}" 
  -d '{"query": "{ adminDashboard { totalUsers totalProjects } }"}'
# Returns error if user doesn't have "admin" role

Resolver-Level Authorization

Beyond directives, you can implement fine-grained authorization directly in resolvers. This is useful for data-dependent rules (e.g., “users can only see their own projects”):

// Field-level auth in resolvers
const resolvers = {
  Query: {
    myProjects: (
      _: unknown, __: unknown, ctx: GraphQLContext,
    ) => {
      if (!ctx.user) throw new Error("Auth required");

      // Filter to only the user's own projects
      return projects.filter(
        (p) => p.ownerId === ctx.user!.sub,
      );
    },
  },

  Project: {
    // Only show billing info to the project owner
    billingInfo: (
      project: { ownerId: string; billingInfo: unknown },
      _: unknown,
      ctx: GraphQLContext,
    ) => {
      if (project.ownerId !== ctx.user?.sub) {
        return null; // Or throw an error
      }
      return project.billingInfo;
    },
  },
};

For complex authorization logic involving resource permissions, consider using Keycloak’s fine-grained authorization with the Authorization Services API.

Federated GraphQL Auth

If you use Apollo Federation with multiple subgraphs, each subgraph should independently validate the JWT. The gateway forwards the Authorization header to subgraphs:

// Apollo Gateway configuration
import { ApolloGateway, RemoteGraphQLDataSource }
  from "@apollo/gateway";

const gateway = new ApolloGateway({
  supergraphSdl: "...",
  buildService({ url }) {
    return new RemoteGraphQLDataSource({
      url,
      willSendRequest({ request, context }) {
        // Forward the auth header to subgraphs
        if (context.token) {
          request.http?.headers.set(
            "Authorization",
            `Bearer ${context.token}`,
          );
        }
      },
    });
  },
});

Each subgraph validates the JWT independently using the same verifyToken function. This ensures that compromising the gateway does not grant access to subgraph data.

Production Considerations

Token Caching

Verifying JWTs against the JWKS endpoint on every request adds latency. The jwks-rsa library caches keys, but you should also consider caching verified token claims with a short TTL:

const tokenCache = new Map<string, {
  claims: KeycloakTokenClaims;
  expiresAt: number;
}>();

async function verifyTokenCached(
  token: string,
): Promise<KeycloakTokenClaims | null> {
  const cached = tokenCache.get(token);
  if (cached && cached.expiresAt > Date.now()) {
    return cached.claims;
  }

  const claims = await verifyToken(token);
  if (claims) {
    // Cache for 60 seconds (adjust based on your needs)
    tokenCache.set(token, {
      claims,
      expiresAt: Date.now() + 60_000,
    });
  }
  return claims;
}

Error Handling

Do not leak internal details in auth errors. Return consistent error codes:

import { GraphQLError } from "graphql";

// Instead of: throw new Error("Token expired at ...")
throw new GraphQLError("Authentication required", {
  extensions: { code: "UNAUTHENTICATED" },
});

throw new GraphQLError("Insufficient permissions", {
  extensions: { code: "FORBIDDEN" },
});

Monitoring

Track authentication metrics: successful auths, failures, token expiration rates. Keycloak provides session management and insights that help you monitor active sessions and detect anomalies.

Conclusion

Securing a GraphQL API with Keycloak involves three layers: verifying JWTs in the context function, enforcing access rules with schema directives, and implementing data-level authorization in resolvers. Apollo Server v4 provides clean extension points for each.

The key takeaways:

  • Use the JWKS endpoint for stateless JWT validation
  • Custom directives (@auth, @hasRole) make authorization declarative
  • WebSocket subscriptions need connection-level authentication
  • Federated subgraphs should independently validate tokens
  • Cache verified claims to reduce latency

For the official Apollo Server v4 documentation, see apollographql.com/docs/apollo-server. For Keycloak OIDC configuration, refer to the Keycloak Securing Applications guide.

Ready to add enterprise-grade authentication to your GraphQL API? Try Skycloak free for a fully managed Keycloak instance with zero infrastructure overhead.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman