Keycloak + GraphQL: Securing Apollo Server APIs
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
- Log in to Keycloak admin at
http://localhost:8080/admin. - Create a realm named
graphql-demo. - 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 |
- Create realm roles:
admin,project-admin,user. - Create a test user and assign the
userrole.
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:
- Go to Client Scopes > Create.
- Name it
graphql-api. - Add a User Attribute mapper or Hardcoded claim mapper for any custom claims your GraphQL resolvers need.
- Assign this scope to the
apollo-appclient.
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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.