Skip to main content

Logto vs Ory vs Keycloak: Open Source Identity Providers 2026

·PkgPulse Team

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

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.