Logto vs Ory vs Keycloak: Open Source Identity Providers 2026
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
| Feature | Logto | Ory Suite | Keycloak |
|---|---|---|---|
| Language | TypeScript/Node.js | Go | Java |
| 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 complexity | Low | High | Very High |
| GitHub stars | 12k | 15k (Hydra) | 22k |
| Managed cloud | ✅ Logto Cloud | ✅ Ory Network | ❌ (RHBK via Red Hat) |
| License | Apache 2.0 | Apache 2.0 | Apache 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.