{
  "slug": "Keycloak---OpenFGA--A-Practical-Integration-Guide-a9ed984ee205",
  "title": "Keycloak + OpenFGA: A Practical Integration Guide",
  "subtitle": "Concern Tool Why Authentication (AuthN) Keycloak JWT issuance, user identity, realm roles, SSO Authorization (AuthZ) OpenFGA Relationship-based permission checks (ReBAC)",
  "excerpt": "Overview",
  "date": "2026-07-20",
  "tags": [
    "Security",
    "Architecture"
  ],
  "readingTime": "9 min",
  "url": "https://medium.datadriveninvestor.com/keycloak-openfga-a-practical-integration-guide-a9ed984ee205",
  "hero": "https://miro.medium.com/v2/resize:fit:1200/1*4lkbdotQol7xSmM-gExbxA.png",
  "content": [
    {
      "type": "heading",
      "level": 2,
      "text": "Overview"
    },
    {
      "type": "paragraph",
      "html": "Concern Tool Why Authentication (AuthN) Keycloak JWT issuance, user identity, realm roles, SSO Authorization (AuthZ) OpenFGA Relationship-based permission checks (ReBAC)"
    },
    {
      "type": "paragraph",
      "html": "<strong>Rule:</strong> Keycloak answers <em>“who are you?”</em>. OpenFGA answers <em>“what can you do?”</em>. Neither tool does both jobs well. Do not conflate them."
    },
    {
      "type": "paragraph",
      "html": "<strong>Performance baseline:</strong> The combined approach yields zero database queries for identity resolution and a single graph traversal per request for authorization — versus the typical 3–4 SQL queries per request in a database-backed RBAC system."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Core Concepts"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Why not Keycloak alone?"
    },
    {
      "type": "paragraph",
      "html": "Keycloak’s built-in authorization services (UMA, policy evaluation) work for simple role checks but degrade quickly when permissions are resource-scoped and hierarchical:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "“User A can edit Project X because they are a participant” — this is a <strong>relationship</strong>, not a role.",
        "“Org admin restricted User A to viewer on Project X” — this is a <strong>contextual override</strong>, not a role assignment.",
        "At 300+ TPS, evaluating these relationships against a SQL database on every request causes measurable latency."
      ]
    },
    {
      "type": "image",
      "src": "https://miro.medium.com/v2/resize:fit:1400/1*4lkbdotQol7xSmM-gExbxA.png",
      "alt": "Keycloak + OpenFGA: A Practical Integration Guide",
      "caption": "",
      "width": 2752,
      "height": 1536
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Why OpenFGA?"
    },
    {
      "type": "paragraph",
      "html": "OpenFGA is built on Google Zanzibar (the permission system behind Google Drive). Its data model — <strong>relation tuples</strong> — maps directly to resource hierarchies:"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "user:alice   →  participant  →  project:alpha\nproject:alpha  →  owned_by   →  org:acme\norg:acme     →  restricts    →  user:alice (viewer only)"
    },
    {
      "type": "paragraph",
      "html": "A single <code>Check</code> call traverses all these relationships in one round trip. The result is <code>ALLOW</code> or <code>DENY</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Data flow"
    },
    {
      "type": "code",
      "lang": "http",
      "code": "POST /projects/:id/bids\n        │\n        ▼\n[1] JWT validation        ← Keycloak public key (in-memory, 0 DB queries)\n        │\n        ▼\n[2] Subscription check    ← Read from JWT claim (in-memory, 0 DB queries)\n        │\n        ▼\n[3] OpenFGA.Check()       ← Single graph traversal\n        │                    resolves project role AND org restrictions\n        ▼\n   Execute handler"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Infrastructure Setup"
    },
    {
      "type": "paragraph",
      "html": "docker-compose.yml"
    },
    {
      "type": "code",
      "lang": "yaml",
      "code": "version: \"3.9\"\n\nservices:\n  postgres:\n    image: postgres:16\n    environment:\n      POSTGRES_USER: appuser\n      POSTGRES_PASSWORD: apppassword\n      POSTGRES_MULTIPLE_DATABASES: appdb,keycloak,openfga\n    volumes:\n      - pg_data:/var/lib/postgresql/data\n    ports:\n      - \"5432:5432\"\n\n  keycloak:\n    image: quay.io/keycloak/keycloak:24.0\n    environment:\n      KC_DB: postgres\n      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak\n      KC_DB_USERNAME: appuser\n      KC_DB_PASSWORD: apppassword\n      KEYCLOAK_ADMIN: admin\n      KEYCLOAK_ADMIN_PASSWORD: changeme\n      KC_HOSTNAME_STRICT: \"false\"\n      KC_HTTP_ENABLED: \"true\"\n    command: start-dev\n    ports:\n      - \"8080:8080\"\n    depends_on:\n      - postgres\n\n  openfga:\n    image: openfga/openfga:latest\n    command: run\n    environment:\n      OPENFGA_DATASTORE_ENGINE: postgres\n      OPENFGA_DATASTORE_URI: >\n        postgres://appuser:apppassword@postgres:5432/openfga?sslmode=disable\n      OPENFGA_AUTHN_METHOD: none   # use preshared key in production\n    ports:\n      - \"8081:8081\"   # HTTP API\n      - \"8082:8082\"   # gRPC\n      - \"3000:3000\"   # Playground UI\n    depends_on:\n      - postgres\n\nvolumes:\n  pg_data:"
    },
    {
      "type": "paragraph",
      "html": "Run: <code>docker compose up -d</code>"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Keycloak Configuration"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "1. Create realm"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "# Using kcadm CLI inside the container\ndocker exec -it <keycloak_container> /opt/keycloak/bin/kcadm.sh \\\n  config credentials \\\n  --server http://localhost:8080 \\\n  --realm master \\\n  --user admin --password changeme\n\ndocker exec -it <keycloak_container> /opt/keycloak/bin/kcadm.sh \\\n  create realms \\\n  -s realm=myapp \\\n  -s enabled=true"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "2. Create platform roles"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "for ROLE in architect general-contractor property-owner consultant platform-admin; do\n  docker exec -it <keycloak_container> /opt/keycloak/bin/kcadm.sh \\\n    create roles \\\n    -r myapp \\\n    -s name=$ROLE\ndone"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "3. Create backend client"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "docker exec -it <keycloak_container> /opt/keycloak/bin/kcadm.sh \\\n  create clients \\\n  -r myapp \\\n  -s clientId=backend-api \\\n  -s enabled=true \\\n  -s \"redirectUris=[\\\"http://localhost:3001/*\\\"]\" \\\n  -s publicClient=false \\\n  -s serviceAccountsEnabled=true"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "4. Add custom JWT claims (Protocol Mappers)"
    },
    {
      "type": "paragraph",
      "html": "In Keycloak Admin UI → Clients → <code>backend-api</code> → Client Scopes → Dedicated scopes → Add mapper → By configuration → User Attribute:"
    },
    {
      "type": "paragraph",
      "html": "Mapper Token Claim Name User Attribute Claim JSON Type Subscription tier <code>subscription_tier</code> <code>subscription_tier</code> String Modules <code>subscription_modules</code> <code>subscription_modules</code> JSON Storage <code>storage_gb</code> <code>storage_gb</code> int"
    },
    {
      "type": "paragraph",
      "html": "Set the attribute on each user after purchase via the Admin API:"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "curl -X PUT http://localhost:8080/admin/realms/myapp/users/{userId} \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"attributes\": {\n      \"subscription_tier\": [\"pro\"],\n      \"subscription_modules\": [\"[\\\"bidding\\\",\\\"material_selection\\\",\\\"procurement\\\"]\"],\n      \"storage_gb\": [\"50\"]\n    }\n  }'"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Resulting JWT payload"
    },
    {
      "type": "code",
      "lang": "json",
      "code": "{\n  \"sub\": \"a3f8c2d1-4e5b-6789-abcd-ef0123456789\",\n  \"email\": \"user@example.com\",\n  \"realm_access\": {\n    \"roles\": [\"general-contractor\"]\n  },\n  \"subscription_tier\": \"pro\",\n  \"subscription_modules\": [\"bidding\", \"material_selection\", \"procurement\"],\n  \"storage_gb\": 50,\n  \"iat\": 1716580000,\n  \"exp\": 1716583600\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "OpenFGA Configuration"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "1. Create a store"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "curl -X POST http://localhost:8081/stores \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\": \"myapp\"}' \\\n  | jq '.id'\n# Save this as FGA_STORE_ID"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "2. Write authorization model"
    },
    {
      "type": "code",
      "lang": "cpp",
      "code": "curl -X POST http://localhost:8081/stores/$FGA_STORE_ID/authorization-models \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n  \"schema_version\": \"1.1\",\n  \"type_definitions\": [\n    {\n      \"type\": \"user\"\n    },\n    {\n      \"type\": \"organization\",\n      \"relations\": {\n        \"owner\":  { \"this\": {} },\n        \"admin\":  { \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"owner\" } }] } },\n        \"manager\":{ \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"admin\" } }] } },\n        \"member\": { \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"manager\" } }] } }\n      },\n      \"metadata\": {\n        \"relations\": {\n          \"owner\":   { \"directly_related_user_types\": [{ \"type\": \"user\" }] },\n          \"admin\":   { \"directly_related_user_types\": [{ \"type\": \"user\" }] },\n          \"manager\": { \"directly_related_user_types\": [{ \"type\": \"user\" }] },\n          \"member\":  { \"directly_related_user_types\": [{ \"type\": \"user\" }] }\n        }\n      }\n    },\n    {\n      \"type\": \"project\",\n      \"relations\": {\n        \"owner\":       { \"this\": {} },\n        \"admin\":       { \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"owner\" } }] } },\n        \"participant\": { \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"admin\" } }] } },\n        \"viewer\":      { \"union\": { \"child\": [{ \"this\": {} }, { \"computedUserset\": { \"relation\": \"participant\" } }] } },\n\n        \"can_view_bidding\":    { \"computedUserset\": { \"relation\": \"viewer\" } },\n        \"can_create_bidding\":  { \"computedUserset\": { \"relation\": \"participant\" } },\n        \"can_confirm_bidding\": { \"computedUserset\": { \"relation\": \"admin\" } },\n\n        \"can_view_materials\":    { \"computedUserset\": { \"relation\": \"viewer\" } },\n        \"can_select_materials\":  { \"computedUserset\": { \"relation\": \"participant\" } },\n        \"can_approve_materials\": { \"computedUserset\": { \"relation\": \"admin\" } },\n\n        \"can_upload_files\":  { \"computedUserset\": { \"relation\": \"participant\" } },\n        \"can_delete_project\":{ \"computedUserset\": { \"relation\": \"owner\" } }\n      },\n      \"metadata\": {\n        \"relations\": {\n          \"owner\":       { \"directly_related_user_types\": [{ \"type\": \"user\" }, { \"type\": \"organization\" }] },\n          \"admin\":       { \"directly_related_user_types\": [{ \"type\": \"user\" }] },\n          \"participant\": { \"directly_related_user_types\": [{ \"type\": \"user\" }] },\n          \"viewer\":      { \"directly_related_user_types\": [{ \"type\": \"user\" }] }\n        }\n      }\n    }\n  ]\n}'"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "3. Write relation tuples"
    },
    {
      "type": "paragraph",
      "html": "Tuples are written by your application when domain events occur — not at request time."
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "# User invited to project as participant\ncurl -X POST http://localhost:8081/stores/$FGA_STORE_ID/write \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"writes\": {\n      \"tuple_keys\": [{\n        \"user\": \"user:alice\",\n        \"relation\": \"participant\",\n        \"object\": \"project:alpha\"\n      }]\n    }\n  }'\n\n# Org admin restricts user to viewer (delete participant, write viewer)\ncurl -X POST http://localhost:8081/stores/$FGA_STORE_ID/write \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"deletes\": {\n      \"tuple_keys\": [{\n        \"user\": \"user:alice\",\n        \"relation\": \"participant\",\n        \"object\": \"project:alpha\"\n      }]\n    },\n    \"writes\": {\n      \"tuple_keys\": [{\n        \"user\": \"user:alice\",\n        \"relation\": \"viewer\",\n        \"object\": \"project:alpha\"\n      }]\n    }\n  }'"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "4. Test a check"
    },
    {
      "type": "code",
      "lang": "cpp",
      "code": "curl -X POST http://localhost:8081/stores/$FGA_STORE_ID/check \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"tuple_key\": {\n      \"user\": \"user:alice\",\n      \"relation\": \"can_create_bidding\",\n      \"object\": \"project:alpha\"\n    }\n  }'\n# { \"allowed\": true }"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "NestJS Integration"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Install dependencies"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "npm install @openfga/sdk @nestjs/jwt jwks-rsa"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Environment variables"
    },
    {
      "type": "code",
      "lang": "dotenv",
      "code": "# .env\nKEYCLOAK_JWKS_URI=http://localhost:8080/realms/myapp/protocol/openid-connect/certs\nKEYCLOAK_ISSUER=http://localhost:8080/realms/myapp\nFGA_API_URL=http://localhost:8081\nFGA_STORE_ID=your-store-id\nFGA_MODEL_ID=your-model-id   # optional; omit to use latest"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "FGA module"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "// fga.module.ts\nimport { Module, Global } from \"@nestjs/common\";\nimport { OpenFgaClient } from \"@openfga/sdk\";\n\n@Global()\n@Module({\n  providers: [\n    {\n      provide: OpenFgaClient,\n      useFactory: () =>\n        new OpenFgaClient({\n          apiUrl: process.env.FGA_API_URL,\n          storeId: process.env.FGA_STORE_ID,\n          authorizationModelId: process.env.FGA_MODEL_ID,\n        }),\n    },\n  ],\n  exports: [OpenFgaClient],\n})\nexport class FgaModule {}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Decorators"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "// auth.decorators.ts\nimport { SetMetadata } from \"@nestjs/common\";\n\nexport interface FgaPermission {\n  relation: string;\n  objectType: string;\n  objectParam: string;  // req.params key that holds the resource ID\n}\n\nexport const RequireModule = (module: string) =>\n  SetMetadata(\"required_module\", module);\n\nexport const FgaCheck = (permission: FgaPermission) =>\n  SetMetadata(\"fga_permission\", permission);"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Auth guard"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "// auth.guard.ts\nimport {\n  Injectable, CanActivate, ExecutionContext,\n  UnauthorizedException, ForbiddenException,\n} from \"@nestjs/common\";\nimport { Reflector } from \"@nestjs/core\";\nimport { JwksClient } from \"jwks-rsa\";\nimport * as jwt from \"jsonwebtoken\";\nimport { OpenFgaClient } from \"@openfga/sdk\";\nimport { FgaPermission } from \"./auth.decorators\";\n\nconst jwksClient = new JwksClient({\n  jwksUri: process.env.KEYCLOAK_JWKS_URI,\n  cache: true,\n  cacheMaxEntries: 10,\n  cacheMaxAge: 600_000, // 10 min\n});\n\n@Injectable()\nexport class AuthGuard implements CanActivate {\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly fga: OpenFgaClient,\n  ) {}\n\n  async canActivate(context: ExecutionContext): Promise<boolean> {\n    const req = context.switchToHttp().getRequest();\n\n    // ── 1. Extract + verify JWT ─────────────────────────────────\n    const token = req.headers.authorization?.replace(\"Bearer \", \"\");\n    if (!token) throw new UnauthorizedException(\"Missing token\");\n\n    const claims = await this.verifyJwt(token);\n\n    // ── 2. Subscription gate (in-memory, zero DB) ───────────────\n    const requiredModule = this.reflector.get<string>(\n      \"required_module\",\n      context.getHandler(),\n    );\n    if (requiredModule) {\n      const modules: string[] = claims.subscription_modules ?? [];\n      if (!modules.includes(requiredModule)) {\n        throw new ForbiddenException(\n          `Your subscription does not include the '${requiredModule}' module`,\n        );\n      }\n    }\n\n    // ── 3. OpenFGA permission check ─────────────────────────────\n    const permission = this.reflector.get<FgaPermission>(\n      \"fga_permission\",\n      context.getHandler(),\n    );\n    if (permission) {\n      const resourceId = req.params[permission.objectParam];\n      if (!resourceId) throw new ForbiddenException(\"Resource ID missing\");\n\n      const { allowed } = await this.fga.check({\n        tuple_key: {\n          user: `user:${claims.sub}`,\n          relation: permission.relation,\n          object: `${permission.objectType}:${resourceId}`,\n        },\n      });\n\n      if (!allowed) throw new ForbiddenException(\"Access denied\");\n    }\n\n    req.user = {\n      id: claims.sub,\n      email: claims.email,\n      role: claims.realm_access?.roles?.[0],\n      subscriptionTier: claims.subscription_tier,\n    };\n\n    return true;\n  }\n\n  private verifyJwt(token: string): Promise<any> {\n    return new Promise((resolve, reject) => {\n      const decoded = jwt.decode(token, { complete: true });\n      if (!decoded?.header?.kid) return reject(new UnauthorizedException(\"Invalid token header\"));\n\n      jwksClient.getSigningKey(decoded.header.kid, (err, key) => {\n        if (err) return reject(new UnauthorizedException(\"Key fetch failed\"));\n        jwt.verify(\n          token,\n          key.getPublicKey(),\n          { issuer: process.env.KEYCLOAK_ISSUER },\n          (verifyErr, payload) => {\n            if (verifyErr) return reject(new UnauthorizedException(\"Token verification failed\"));\n            resolve(payload);\n          },\n        );\n      });\n    });\n  }\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Controller usage"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "// bidding.controller.ts\nimport { Controller, Post, Get, Param, UseGuards } from \"@nestjs/common\";\nimport { AuthGuard } from \"./auth.guard\";\nimport { RequireModule, FgaCheck } from \"./auth.decorators\";\n\n@Controller(\"projects/:projectId/bidding\")\n@UseGuards(AuthGuard)\nexport class BiddingController {\n\n  // Requires: valid JWT + bidding module in subscription + participant on project\n  @Post()\n  @RequireModule(\"bidding\")\n  @FgaCheck({ relation: \"can_create_bidding\", objectType: \"project\", objectParam: \"projectId\" })\n  async submitBid(@Param(\"projectId\") projectId: string) {\n    return this.biddingService.create(projectId);\n  }\n\n  // Requires: valid JWT + bidding module + viewer on project (minimum)\n  @Get()\n  @RequireModule(\"bidding\")\n  @FgaCheck({ relation: \"can_view_bidding\", objectType: \"project\", objectParam: \"projectId\" })\n  async listBids(@Param(\"projectId\") projectId: string) {\n    return this.biddingService.findAll(projectId);\n  }\n\n  // Requires: valid JWT + admin on project\n  @Post(\":bidId/confirm\")\n  @FgaCheck({ relation: \"can_confirm_bidding\", objectType: \"project\", objectParam: \"projectId\" })\n  async confirmBid(@Param(\"projectId\") projectId: string) {\n    return this.biddingService.confirm(projectId);\n  }\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Tuple sync service"
    },
    {
      "type": "paragraph",
      "html": "Write tuples when domain events occur. Never at request time."
    },
    {
      "type": "code",
      "lang": "php",
      "code": "// permission-sync.service.ts\nimport { Injectable } from \"@nestjs/common\";\nimport { OpenFgaClient } from \"@openfga/sdk\";\n\ntype ProjectRole = \"owner\" | \"admin\" | \"participant\" | \"viewer\";\n\n@Injectable()\nexport class PermissionSyncService {\n  constructor(private readonly fga: OpenFgaClient) {}\n\n  async grantProjectRole(userId: string, projectId: string, role: ProjectRole) {\n    await this.fga.write({\n      writes: {\n        tuple_keys: [{\n          user: `user:${userId}`,\n          relation: role,\n          object: `project:${projectId}`,\n        }],\n      },\n    });\n  }\n\n  async changeProjectRole(\n    userId: string,\n    projectId: string,\n    from: ProjectRole,\n    to: ProjectRole,\n  ) {\n    await this.fga.write({\n      deletes: { tuple_keys: [{ user: `user:${userId}`, relation: from, object: `project:${projectId}` }] },\n      writes:  { tuple_keys: [{ user: `user:${userId}`, relation: to,   object: `project:${projectId}` }] },\n    });\n  }\n\n  async revokeProjectAccess(userId: string, projectId: string, role: ProjectRole) {\n    await this.fga.write({\n      deletes: {\n        tuple_keys: [{\n          user: `user:${userId}`,\n          relation: role,\n          object: `project:${projectId}`,\n        }],\n      },\n    });\n  }\n\n  async addOrgMember(userId: string, orgId: string, role: \"admin\" | \"manager\" | \"member\") {\n    await this.fga.write({\n      writes: {\n        tuple_keys: [{\n          user: `user:${userId}`,\n          relation: role,\n          object: `organization:${orgId}`,\n        }],\n      },\n    });\n  }\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Permission Check Reference"
    },
    {
      "type": "image",
      "src": "https://miro.medium.com/v2/resize:fit:1400/1*uDTX8r8KKkNPhWX64KQhcQ.png",
      "alt": "Keycloak + OpenFGA: A Practical Integration Guide",
      "caption": "",
      "width": 637,
      "height": 401
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Request Overhead Comparison"
    },
    {
      "type": "image",
      "src": "https://miro.medium.com/v2/resize:fit:1400/1*4hgb6GHMPyWv9jAzvC2kNg.png",
      "alt": "Keycloak + OpenFGA: A Practical Integration Guide",
      "caption": "",
      "width": 656,
      "height": 216
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Production Checklist"
    },
    {
      "type": "code",
      "lang": "typescript",
      "code": "Keycloak\n  [ ] Switch from start-dev to start (production mode)\n  [ ] Configure TLS termination (nginx/Caddy in front)\n  [ ] Set KC_HOSTNAME to your public domain\n  [ ] Enable brute force protection in realm settings\n  [ ] Rotate admin credentials\n  [ ] Configure SMTP for email verification\n\nOpenFGA\n  [ ] Set OPENFGA_AUTHN_METHOD=preshared and rotate key\n  [ ] Enable TLS on the gRPC port\n  [ ] Set up PostgreSQL connection pooling (PgBouncer)\n  [ ] Add read replicas for FGA DB under high read load\n  [ ] Monitor tuple write latency — spikes indicate missing indexes\n\nApplication\n  [ ] Cache JWKS keys (jwks-rsa caches by default; verify TTL)\n  [ ] Add OpenFGA circuit breaker (fail-closed on FGA timeout)\n  [ ] Log all DENY decisions with user ID + resource + relation\n  [ ] Write integration tests per permission scenario, not per endpoint"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Limitations and Trade-offs"
    },
    {
      "type": "paragraph",
      "html": "<strong>Keycloak</strong>"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "JVM-based — minimum ~512MB RAM. Use Authentik or Casdoor if memory is constrained.",
        "UI-heavy to configure; scripting via kcadm or Terraform provider recommended for reproducibility.",
        "Custom claim mappers require UI or API steps that are easy to miss."
      ]
    },
    {
      "type": "paragraph",
      "html": "<strong>OpenFGA</strong>"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "Does not handle authentication — you still need Keycloak (or equivalent) for JWT issuance.",
        "Authorization model changes are additive-safe but deletions require care — removing a relation type that has live tuples causes silent failures.",
        "No built-in audit log UI; implement your own tuple change log via event hooks.",
        "<code>ListObjects</code> (fetch all resources a user can access) is expensive on large graphs — paginate and cache results."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Further Reading"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<a href=\"https://openfga.dev/docs\" target=\"_blank\" rel=\"noreferrer noopener\">OpenFGA documentation</a>",
        "<a href=\"http://localhost:3000\" target=\"_blank\" rel=\"noreferrer noopener\">OpenFGA authorization model playground</a> (runs locally after docker compose up)",
        "<a href=\"https://www.keycloak.org/docs/latest/server_admin/\" target=\"_blank\" rel=\"noreferrer noopener\">Keycloak Server Administration Guide</a>",
        "<a href=\"https://research.google/pubs/pub48190/\" target=\"_blank\" rel=\"noreferrer noopener\">Google Zanzibar paper</a> — the academic foundation for OpenFGA",
        "<a href=\"https://github.com/openfga/js-sdk\" target=\"_blank\" rel=\"noreferrer noopener\">OpenFGA SDK for Node.js</a>"
      ]
    },
    {
      "type": "paragraph",
      "html": "<strong>Tags:</strong> <code>Keycloak</code> <code>OpenFGA</code> <code>Authorization</code> <code>Authentication</code> <code>NestJS</code> <code>ReBAC</code> <code>JWT</code> <code>Self-Hosted</code> <code>Open Source</code> <code>Backend</code>"
    }
  ]
}
