Skip to main content

Guide

Logto vs Ory vs Keycloak (2026)

Logto vs Ory Kratos/Hydra vs Keycloak for open source OAuth/OIDC identity. Compare Node.js SDK integration, multi-tenancy, RBAC, DX, and self-hosting.

·PkgPulse Team·
0

Logto vs Ory vs Keycloak: Open Source Identity Providers 2026

TL;DR

Logto is the modern developer-first identity platform — beautiful UI, clean TypeScript SDK, built-in multi-tenancy, and social logins in minutes. Ory is a headless, API-first suite (Hydra + Kratos + Keto + Oathkeeper) for teams that need maximum flexibility and already have their own UI. Keycloak is the enterprise workhorse — battle-tested, feature-complete, but complex to configure and resource-hungry. If you're building a B2B SaaS product in 2026, start with Logto; if you're in enterprise with complex compliance requirements, Keycloak; if your team needs custom flows and has the engineering bandwidth, Ory.

Key Takeaways

  • Logto GitHub stars: ~12k — fastest-growing open-source IdP in the JS ecosystem in 2025–2026
  • Keycloak GitHub stars: ~22k — the most mature option, Red Hat-backed, used by enterprises globally
  • Ory GitHub (Hydra): ~15k — the "build your own" identity system, maximum composability
  • Logto ships with a management console — full UI for managing users, organizations, and sign-in flows
  • Keycloak requires significant Java/JVM resources — minimum 512 MB RAM, typically 1–2 GB in production
  • Ory runs as lightweight Go microservices — ~50 MB RAM per service, ideal for Kubernetes
  • All three support OIDC, OAuth 2.1, PKCE, and social logins — the APIs are standards-compliant

Why Open Source Identity?

Auth0, Clerk, and WorkOS are excellent managed identity services, but they come with tradeoffs: vendor lock-in, per-MAU pricing that compounds at scale, and limited control over the authentication UX.

Open-source identity providers give you full control — your data stays in your infrastructure, you can customize every flow, and you pay only for the compute. The tradeoff is operational complexity: you maintain the service, handle upgrades, and ensure availability.

In 2026, this decision often comes down to: compliance (healthcare, fintech, government often require self-hosted), cost (100k+ MAU is where managed pricing gets painful), or customization (deep integration into an existing user management system).


Logto: Developer-First Identity Platform

Logto is built for developers building modern SaaS products. It prioritizes setup speed, TypeScript ergonomics, and multi-tenancy (organizations/workspaces) — features that Keycloak requires significant configuration to achieve.

Docker Setup

# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: logto
      POSTGRES_PASSWORD: logto_secret
      POSTGRES_DB: logto
    volumes:
      - logto_data:/var/lib/postgresql/data

  logto:
    image: svhd/logto:latest
    ports:
      - "3001:3001"  # Main API + OIDC
      - "3002:3002"  # Admin console
    environment:
      TRUST_PROXY_HEADER: 1
      DB_URL: postgresql://logto:logto_secret@postgres:5432/logto
      ENDPOINT: http://localhost:3001
      ADMIN_ENDPOINT: http://localhost:3002
    command: ["sh", "-c", "npx @logto/cli db seed --db-url $DB_URL && node /etc/logto/node_modules/@logto/core/dist/index.js"]
    depends_on:
      - postgres

volumes:
  logto_data:

Node.js Express Integration

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

const app = express();

// Logto's OIDC discovery endpoint
const LOGTO_ENDPOINT = "http://localhost:3001";

// Verify JWT tokens from Logto
const JWKS = createRemoteJWKSet(
  new URL(`${LOGTO_ENDPOINT}/oidc/jwks`)
);

export async function verifyLogtoToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: `${LOGTO_ENDPOINT}/oidc`,
    audience: "https://api.yourapp.com",
  });
  return payload;
}

// Auth middleware
const authenticate = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing token" });
  }

  try {
    const token = authHeader.slice(7);
    const payload = await verifyLogtoToken(token);
    req.user = payload;
    next();
  } catch (err) {
    res.status(401).json({ error: "Invalid token" });
  }
};

app.get("/api/profile", authenticate, (req, res) => {
  res.json({
    userId: req.user.sub,
    organizationId: req.user.organization_id,
    roles: req.user.roles,
  });
});

Next.js Integration (App Router)

// lib/logto.ts
import LogtoClient from "@logto/next";

export const logtoClient = new LogtoClient({
  appId: process.env.LOGTO_APP_ID!,
  appSecret: process.env.LOGTO_APP_SECRET!,
  endpoint: process.env.LOGTO_ENDPOINT!,
  baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
  cookieSecret: process.env.LOGTO_COOKIE_SECRET!,
  cookieSecure: process.env.NODE_ENV === "production",
  scopes: ["openid", "profile", "email", "phone", "organizations"],
  resources: ["https://api.yourapp.com"],
});

// app/api/logto/[...logto]/route.ts
import { logtoClient } from "@/lib/logto";
export const GET = logtoClient.handleAuthRoutes();
export const POST = logtoClient.handleAuthRoutes();

// middleware.ts — protect routes
export default logtoClient.withLogtoApiRoute(
  async (req) => {
    const session = await logtoClient.getLogtoSession(req);
    if (!session) {
      return new Response("Unauthorized", { status: 401 });
    }
    return new Response(JSON.stringify(session.claims));
  }
);

Management API — User and Organization Management

import { LogtoManagementApiClient } from "@logto/node";

const management = new LogtoManagementApiClient({
  endpoint: process.env.LOGTO_ENDPOINT!,
  // Use M2M app credentials for management API
  m2mAppId: process.env.LOGTO_M2M_APP_ID!,
  m2mAppSecret: process.env.LOGTO_M2M_APP_SECRET!,
});

// Create a user programmatically
const user = await management.users.create({
  primaryEmail: "user@example.com",
  primaryPhone: "+14155552671",
  username: "johndoe",
  name: "John Doe",
  password: "SecurePassword123!",
  customData: { plan: "pro", company: "Acme Corp" },
});

// Create an organization (multi-tenancy)
const org = await management.organizations.create({
  name: "Acme Corporation",
  description: "Acme's workspace",
  customData: { plan: "enterprise", seats: 50 },
});

// Add user to organization with role
await management.organizations.addMember(org.id, user.id);
await management.organizations.assignUserRole(org.id, user.id, orgRoleId);

// Get users with pagination
const users = await management.users.getList({
  page: 1,
  pageSize: 20,
  search: "john",
});

Ory: Headless, API-First Identity Suite

Ory is not a single product — it's a suite of microservices:

  • Ory Hydra — OAuth 2.1 / OIDC server (tokens and authorization)
  • Ory Kratos — Self-service identity (registration, login, recovery, profile)
  • Ory Keto — Zanzibar-style permissions (Google Docs-level RBAC)
  • Ory Oathkeeper — API proxy for authentication/authorization

You bring your own UI. Ory handles the protocols.

Ory Kratos: Self-Service Identity Flows

# Start Kratos with SQLite (dev) + mail (dev SMTP)
docker run -d --name kratos \
  -p 4433:4433 -p 4434:4434 \
  -v $(pwd)/kratos.yml:/etc/config/kratos/kratos.yml \
  oryd/kratos:v1.2 serve \
  --config /etc/config/kratos/kratos.yml \
  --dev
# kratos.yml
dsn: sqlite:///var/lib/sqlite/db.sqlite?_fk=true

serve:
  public:
    base_url: http://localhost:4433/
  admin:
    base_url: http://localhost:4434/

selfservice:
  default_browser_return_url: http://localhost:3000/
  allowed_return_urls:
    - http://localhost:3000

  flows:
    registration:
      ui_url: http://localhost:3000/registration
      after:
        default_browser_return_url: http://localhost:3000/

    login:
      ui_url: http://localhost:3000/login

    recovery:
      enabled: true
      ui_url: http://localhost:3000/recovery

  methods:
    password:
      enabled: true
    oidc:
      enabled: true
      config:
        providers:
          - id: google
            provider: google
            client_id: "$GOOGLE_CLIENT_ID"
            client_secret: "$GOOGLE_CLIENT_SECRET"
            scope:
              - email
              - profile

identity:
  schemas:
    - id: default
      url: file:///etc/schemas/identity.schema.json

courier:
  smtp:
    connection_uri: smtp://mailhog:25/?skip_ssl_verify=true

Ory Kratos Node.js SDK

import { Configuration, FrontendApi, IdentityApi } from "@ory/client";

// Frontend SDK — used in your UI
const frontend = new FrontendApi(
  new Configuration({ basePath: "http://localhost:4433" })
);

// Admin SDK — used in your backend
const identity = new IdentityApi(
  new Configuration({ basePath: "http://localhost:4434" })
);

// Get login flow (browser redirects to your UI with flowId)
app.get("/login", async (req, res) => {
  const flowId = req.query.flow as string;

  if (!flowId) {
    // Initiate login flow
    const response = await frontend.createBrowserLoginFlow({
      returnTo: "http://localhost:3000/dashboard",
    });
    return res.redirect(response.data.request_url!);
  }

  // Fetch flow data to render the UI
  const flow = await frontend.getLoginFlow({ id: flowId });
  res.render("login", { flow: flow.data });
});

// Submit login
app.post("/login", async (req, res) => {
  const { flow, ...body } = req.body;
  const response = await frontend.updateLoginFlow({
    flow,
    updateLoginFlowBody: { method: "password", ...body },
  });
  res.redirect(response.data.redirect_browser_to!);
});

// Verify session (middleware)
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const session = await frontend.toSession({
      cookie: req.headers.cookie,
    });
    req.user = session.data.identity;
    next();
  } catch {
    res.redirect("/login");
  }
};

// Admin: manage identities
const users = await identity.listIdentities({ pageSize: 100 });
const user = await identity.getIdentity({ id: userId });

await identity.updateIdentity({
  id: userId,
  updateIdentityBody: {
    schema_id: "default",
    traits: { email: "new@example.com", name: "New Name" },
    state: "active",
  },
});

Ory Hydra: OAuth 2.1 Server

import { OAuth2Api, Configuration } from "@ory/client";

const hydra = new OAuth2Api(
  new Configuration({ basePath: "http://localhost:4445" }) // admin port
);

// Handle OAuth consent (your app must implement this)
app.get("/consent", async (req, res) => {
  const challenge = req.query.consent_challenge as string;

  const { data: consentRequest } = await hydra.getOAuth2ConsentRequest({
    consentChallenge: challenge,
  });

  if (consentRequest.skip) {
    // Auto-accept if already consented
    const { data: response } = await hydra.acceptOAuth2ConsentRequest({
      consentChallenge: challenge,
      acceptOAuth2ConsentRequest: {
        grant_scope: consentRequest.requested_scope,
        session: {
          id_token: { email: consentRequest.subject },
        },
      },
    });
    return res.redirect(response.redirect_to);
  }

  res.render("consent", { consentRequest });
});

Keycloak: The Enterprise Standard

Keycloak is backed by Red Hat and has been the enterprise identity standard for over a decade. It's feature-complete but comes with Java complexity, significant memory requirements, and a steep learning curve.

Docker Setup

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak_secret

  keycloak:
    image: quay.io/keycloak/keycloak:24.0
    ports:
      - "8080:8080"
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak_secret
      KC_HOSTNAME: localhost
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin_secret
    command: start-dev
    depends_on:
      - postgres

Node.js Integration with Keycloak

import KcAdminClient from "@keycloak/keycloak-admin-client";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

// Admin client
const kcAdmin = new KcAdminClient({
  baseUrl: "http://localhost:8080",
  realmName: "master",
});

await kcAdmin.auth({
  grantType: "client_credentials",
  clientId: "admin-cli",
  clientSecret: process.env.KC_ADMIN_SECRET!,
});

// Create realm
await kcAdmin.realms.create({
  realm: "myapp",
  enabled: true,
  displayName: "My Application",
  registrationAllowed: true,
  loginWithEmailAllowed: true,
  duplicateEmailsAllowed: false,
  sslRequired: "external",
  attributes: {
    frontendUrl: "https://auth.yourapp.com",
  },
});

// Create client (your app)
kcAdmin.setConfig({ realmName: "myapp" });
const client = await kcAdmin.clients.create({
  clientId: "my-frontend-app",
  publicClient: true,
  redirectUris: ["http://localhost:3000/*"],
  webOrigins: ["http://localhost:3000"],
  standardFlowEnabled: true,
  directAccessGrantsEnabled: false,
});

// Create user
const user = await kcAdmin.users.create({
  realm: "myapp",
  username: "johndoe",
  email: "john@example.com",
  emailVerified: true,
  enabled: true,
  firstName: "John",
  lastName: "Doe",
  credentials: [
    { type: "password", value: "SecurePassword!", temporary: false },
  ],
});

// JWT verification middleware
const jwks = jwksClient({
  jwksUri: "http://localhost:8080/realms/myapp/protocol/openid-connect/certs",
});

const verifyKeycloakToken = async (token: string) => {
  const decoded = jwt.decode(token, { complete: true });
  const key = await jwks.getSigningKey(decoded?.header.kid);
  return jwt.verify(token, key.getPublicKey(), {
    issuer: "http://localhost:8080/realms/myapp",
    audience: "my-frontend-app",
  });
};

app.get("/api/protected", async (req, res) => {
  const token = req.headers.authorization?.slice(7);
  if (!token) return res.status(401).json({ error: "No token" });

  try {
    const payload = await verifyKeycloakToken(token);
    res.json({ user: payload });
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
});

Keycloak Groups, Roles, and RBAC

// Create realm role
const role = await kcAdmin.roles.create({
  name: "admin",
  description: "Application administrator",
});

// Create group
const group = await kcAdmin.groups.create({
  name: "Admins",
});

// Assign role to group
await kcAdmin.groups.addRealmRoleMappings({
  id: group.id!,
  roles: [{ id: role.id!, name: role.name! }],
});

// Add user to group
await kcAdmin.users.addToGroup({
  id: user.id!,
  groupId: group.id!,
});

// JWT payload includes roles:
// payload.realm_access.roles = ["admin", "default-roles-myapp"]
// payload.resource_access["my-frontend-app"].roles = ["app-admin"]

Feature Comparison

FeatureLogtoOry SuiteKeycloak
LanguageTypeScript/Node.jsGoJava
Memory (idle)~100 MB~50 MB/service~512 MB–1 GB
Multi-tenancy / orgs✅ Native❌ (DIY via Keto)✅ (Realms)
Admin console UI✅ Modern❌ (headless)✅ (complex)
Custom UI flows✅ (required)✅ (via themes)
Social logins✅ 20+ providers✅ (Kratos config)✅ 20+ providers
RBAC✅ Built-in✅ (Keto/Zanzibar)✅ (Roles + Groups)
Passwordless / magic links✅ (via extensions)
Passkeys / WebAuthn
MFA / TOTP
SCIM provisioning
Machine-to-machine auth✅ (Hydra)
SDK quality (Node.js)⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Setup complexityLowHighVery High
GitHub stars12k15k (Hydra)22k
Managed cloud✅ Logto Cloud✅ Ory Network❌ (RHBK via Red Hat)
LicenseApache 2.0Apache 2.0Apache 2.0

When to Use Each

Choose Logto if:

  • You're building a B2B SaaS with organizations/workspaces (multi-tenancy)
  • Your team is JavaScript/TypeScript-first and wants a modern SDK
  • You want a self-hosted alternative to Clerk or Auth0 without enterprise complexity
  • You need a beautiful user portal out of the box (management console + sign-in pages)

Choose Ory if:

  • You need complete control over the authentication UI — every pixel, every flow
  • You're building a platform where different customers need different identity experiences
  • Your team can invest in understanding each microservice (Hydra + Kratos + Keto)
  • You're in a Kubernetes environment and want 50 MB Go services instead of 1 GB Java

Choose Keycloak if:

  • You're in an enterprise environment with existing Keycloak deployments
  • You need LDAP/Active Directory integration (Keycloak's killer feature)
  • Compliance requirements specify specific certifications (Keycloak has FIPS 140-2 compliance)
  • You have a dedicated team for identity infrastructure

Consider managed alternatives instead if:

  • Your MAU is under 50k — Clerk, Auth0, or WorkOS will be cheaper and faster
  • Your team doesn't want to own identity infrastructure uptime

Multi-Tenancy Architecture Patterns

Multi-tenancy is one of the most complex identity requirements for B2B SaaS products, and the three platforms approach it differently with real architectural consequences. Logto's Organizations feature is built natively into the core data model — every user can belong to multiple organizations, each organization has its own role assignments and member management, and the organization context is included in the JWT payload via the organization_id claim. This first-class multi-tenancy support means you can implement workspace-based access control (like Slack workspaces or Notion teams) without custom identity server configuration or schema extensions.

Keycloak's multi-tenancy model uses Realms — each realm is an isolated identity namespace with its own users, clients, roles, and OIDC endpoints. This is powerful for true multi-tenant SaaS where each customer organization gets a completely isolated identity space (separate password policies, separate MFA requirements, separate user pools), but it creates operational complexity: realm provisioning, cross-realm federation, and realm-level quota management all require automation. Keycloak's admin API supports programmatic realm creation, but each realm adds memory overhead and management surface area. Ory's multi-tenancy is the most flexible and the most demanding: Ory Keto's Zanzibar-inspired permission model can express arbitrary tenancy boundaries, but you must design and implement the data model, the permission graph, and the UI flows from scratch. Teams choosing Ory for multi-tenant SaaS should expect significant investment in the identity layer before shipping any product features.

Migration from Managed Identity Providers

Teams migrating from Auth0, Clerk, or WorkOS to a self-hosted identity provider face a data portability challenge: user credentials (password hashes) cannot be exported from managed platforms in a format importable directly to the new system. Auth0 provides a password migration feature that intercepts logins to the old system and transparently migrates credentials as users authenticate — Logto and Keycloak both support this "lazy migration" pattern via their extensible authentication pipelines. The migration period requires running both the old and new identity systems simultaneously, with the old system as the source of truth until all active users have authenticated through the new system at least once.

Keycloak's user federation feature can be pointed at an external HTTP endpoint that validates credentials against the legacy system — a clean way to migrate large user bases without a hard cutover. Logto provides a similar webhook-based approach in its user import flow. Ory Kratos' credentials API supports bulk user import with pre-hashed passwords (bcrypt, scrypt, argon2id, pbkdf2) from systems that export hashes, which covers migrations from many open-source systems. For teams migrating from Clerk or Auth0 (which do not export password hashes), the lazy migration pattern is the practical path, and all three platforms support it through their extensible authentication hooks.

Methodology

Data from official GitHub repositories (star counts as of February 2026), official documentation, and community discussions on Discord. Memory benchmarks from documentation and community production reports. Feature matrix verified against current stable releases: Logto 1.18, Ory Kratos 1.2, Ory Hydra 2.2, Keycloak 24.0.


Related: WorkOS vs Stytch vs FusionAuth for managed identity platforms, or Better Auth vs Lucia vs NextAuth for library-first auth solutions.

See also: oslo vs arctic vs jose: JWT Auth Libraries 2026 and Passport vs NextAuth: Express vs Next.js Auth 2026

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.