SuperTokens vs Hanko vs Authelia: Self-Hosted Authentication (2026)
TL;DR
SuperTokens is the open-source authentication platform — email/password, passwordless, social login, session management, React/Node.js SDKs, self-hosted or managed cloud. Hanko is the passkey-first authentication — WebAuthn/passkeys, passwordless, web components, server-side API, open-source, FIDO2-native. Authelia is the self-hosted SSO portal — two-factor authentication, single sign-on, LDAP/Active Directory, reverse proxy integration, security policies. In 2026: SuperTokens for full-featured self-hosted auth, Hanko for passkey-first passwordless, Authelia for SSO gateway and 2FA.
Key Takeaways
- SuperTokens: supertokens-node ~30K weekly downloads — full auth suite, React SDK, sessions
- Hanko: @teamhanko/hanko-elements ~5K weekly downloads — passkeys, WebAuthn, web components
- Authelia: 23K+ GitHub stars — SSO portal, 2FA, reverse proxy, LDAP
- SuperTokens provides the most complete auth SDK for application developers
- Hanko leads in passkey/WebAuthn-first authentication
- Authelia excels as a centralized SSO gateway for multiple services
SuperTokens
SuperTokens — open-source authentication:
Setup
npm install supertokens-node supertokens-auth-react
// backend/supertokens.ts — Node.js/Express setup
import supertokens from "supertokens-node"
import Session from "supertokens-node/recipe/session"
import EmailPassword from "supertokens-node/recipe/emailpassword"
import ThirdParty from "supertokens-node/recipe/thirdparty"
import Passwordless from "supertokens-node/recipe/passwordless"
import UserRoles from "supertokens-node/recipe/userroles"
supertokens.init({
framework: "express",
supertokens: {
connectionURI: "http://localhost:3567", // Self-hosted core
// Or managed: connectionURI: "https://try.supertokens.com"
},
appInfo: {
appName: "PkgPulse",
apiDomain: "http://localhost:4000",
websiteDomain: "http://localhost:3000",
apiBasePath: "/auth",
websiteBasePath: "/auth",
},
recipeList: [
EmailPassword.init({
signUpFeature: {
formFields: [
{ id: "email" },
{ id: "password" },
{ id: "name", optional: false },
],
},
override: {
apis: (originalImplementation) => ({
...originalImplementation,
signUpPOST: async (input) => {
const response = await originalImplementation.signUpPOST!(input)
if (response.status === "OK") {
// Custom logic after signup:
console.log(`New user: ${response.user.id}`)
await sendWelcomeEmail(response.user.emails[0])
}
return response
},
}),
},
}),
ThirdParty.init({
signInAndUpFeature: {
providers: [
{
config: {
thirdPartyId: "google",
clients: [{
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}],
},
},
{
config: {
thirdPartyId: "github",
clients: [{
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}],
},
},
],
},
}),
Passwordless.init({
contactMethod: "EMAIL_OR_PHONE",
flowType: "USER_INPUT_CODE_AND_MAGIC_LINK",
}),
Session.init({
cookieSameSite: "lax",
sessionTokenFrontendDomain: ".example.com",
override: {
functions: (originalImplementation) => ({
...originalImplementation,
createNewSession: async (input) => {
// Add custom claims to session:
const userRoles = await UserRoles.getRolesForUser(
input.tenantId, input.userId
)
input.accessTokenPayload = {
...input.accessTokenPayload,
roles: userRoles.roles,
}
return originalImplementation.createNewSession(input)
},
}),
},
}),
UserRoles.init(),
],
})
React integration
// frontend/App.tsx
import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react"
import EmailPassword from "supertokens-auth-react/recipe/emailpassword"
import ThirdParty from "supertokens-auth-react/recipe/thirdparty"
import Session from "supertokens-auth-react/recipe/session"
import { SessionAuth } from "supertokens-auth-react/recipe/session"
SuperTokens.init({
appInfo: {
appName: "PkgPulse",
apiDomain: "http://localhost:4000",
websiteDomain: "http://localhost:3000",
apiBasePath: "/auth",
websiteBasePath: "/auth",
},
recipeList: [
EmailPassword.init(),
ThirdParty.init({
signInAndUpFeature: {
providers: [
ThirdParty.Google.init(),
ThirdParty.Github.init(),
],
},
}),
Session.init(),
],
})
function App() {
return (
<SuperTokensWrapper>
<Routes>
{/* Auth pages (auto-generated UI): */}
{getSuperTokensRoutesForReactRouterDom(reactRouterDom, [
EmailPasswordPreBuiltUI,
ThirdPartyPreBuiltUI,
])}
{/* Protected route: */}
<Route
path="/dashboard"
element={
<SessionAuth>
<Dashboard />
</SessionAuth>
}
/>
</Routes>
</SuperTokensWrapper>
)
}
// Protected API call:
import Session from "supertokens-auth-react/recipe/session"
async function fetchUserData() {
const response = await fetch("http://localhost:4000/api/user", {
headers: {
// SuperTokens automatically attaches session tokens
},
})
return response.json()
}
// Access session info:
function Dashboard() {
const session = Session.useSessionContext()
if (session.loading) return <div>Loading...</div>
return (
<div>
<p>User ID: {session.userId}</p>
<p>Roles: {session.accessTokenPayload.roles?.join(", ")}</p>
<button onClick={() => Session.signOut()}>Sign Out</button>
</div>
)
}
API protection
// Express middleware:
import { verifySession } from "supertokens-node/recipe/session/framework/express"
import UserRoles from "supertokens-node/recipe/userroles"
// Protect route:
app.get("/api/profile", verifySession(), async (req, res) => {
const userId = req.session!.getUserId()
const payload = req.session!.getAccessTokenPayload()
res.json({ userId, roles: payload.roles })
})
// Role-based access:
app.delete(
"/api/admin/users/:id",
verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
],
}),
async (req, res) => {
// Only admins can reach here
await deleteUser(req.params.id)
res.json({ deleted: true })
}
)
Hanko
Hanko — passkey-first authentication:
Setup
npm install @teamhanko/hanko-elements
# Self-hosted Hanko backend:
docker run -d \
--name hanko \
-p 8000:8000 \
-e "HANKO_PUBLIC_URL=http://localhost:8000" \
-e "HANKO_SECRETS_SESSION=your-session-secret" \
-e "HANKO_DATABASE_URL=postgres://user:pass@db:5432/hanko" \
teamhanko/hanko:latest
Web components (framework-agnostic)
<!-- Vanilla HTML — drop-in auth UI: -->
<script type="module">
import { register } from "https://esm.sh/@teamhanko/hanko-elements"
await register("http://localhost:8000")
</script>
<!-- Login/Register component (handles passkeys + email): -->
<hanko-auth></hanko-auth>
<!-- User profile management: -->
<hanko-profile></hanko-profile>
<!-- Passkey-only login button: -->
<hanko-passkey-login></hanko-passkey-login>
React integration
// components/HankoAuth.tsx
import { useEffect, useCallback } from "react"
import { register } from "@teamhanko/hanko-elements"
import { useRouter } from "next/navigation"
const HANKO_API_URL = process.env.NEXT_PUBLIC_HANKO_API_URL!
export function HankoAuth() {
const router = useRouter()
const redirectAfterLogin = useCallback(() => {
router.replace("/dashboard")
}, [router])
useEffect(() => {
register(HANKO_API_URL).catch(console.error)
}, [])
useEffect(() => {
document.addEventListener("hankoAuthSuccess", redirectAfterLogin)
return () => {
document.removeEventListener("hankoAuthSuccess", redirectAfterLogin)
}
}, [redirectAfterLogin])
return <hanko-auth />
}
// components/HankoProfile.tsx
export function HankoProfile() {
useEffect(() => {
register(HANKO_API_URL).catch(console.error)
}, [])
return <hanko-profile />
}
// Custom styling:
// components/hanko-auth.css
// hanko-auth,
// hanko-profile {
// --color: #ffffff;
// --color-shade-1: #8e8e8e;
// --color-shade-2: #545454;
// --brand-color: #6366f1;
// --brand-color-shade-1: #4f46e5;
// --brand-contrast-color: #ffffff;
// --background-color: #0a0a0a;
// --border-radius: 8px;
// --font-family: "Inter", sans-serif;
// --font-size: 14px;
// }
Hanko SDK (session management)
// lib/hanko.ts
import { Hanko } from "@teamhanko/hanko-elements"
const HANKO_API_URL = process.env.NEXT_PUBLIC_HANKO_API_URL!
const hanko = new Hanko(HANKO_API_URL)
// Check if user is logged in:
export async function isLoggedIn(): Promise<boolean> {
try {
const session = hanko.session.get()
return session?.isValid ?? false
} catch {
return false
}
}
// Get current user:
export async function getCurrentUser() {
const user = await hanko.user.getCurrent()
return {
id: user.id,
email: user.email,
webauthnCredentials: user.webauthn_credentials,
createdAt: user.created_at,
}
}
// Logout:
export async function logout() {
await hanko.user.logout()
}
// Listen for auth events:
hanko.onAuthFlowCompleted((detail) => {
console.log(`User ${detail.userID} logged in`)
})
hanko.onSessionExpired(() => {
console.log("Session expired")
window.location.href = "/login"
})
hanko.onUserDeleted(() => {
console.log("User account deleted")
window.location.href = "/"
})
Middleware (Next.js)
// middleware.ts — protect routes with Hanko session
import { NextRequest, NextResponse } from "next/server"
import { jwtVerify, createRemoteJWKSet } from "jose"
const HANKO_API_URL = process.env.NEXT_PUBLIC_HANKO_API_URL!
export async function middleware(req: NextRequest) {
const token = req.cookies.get("hanko")?.value
if (!token) {
return NextResponse.redirect(new URL("/login", req.url))
}
try {
const JWKS = createRemoteJWKSet(
new URL(`${HANKO_API_URL}/.well-known/jwks.json`)
)
const { payload } = await jwtVerify(token, JWKS)
// Add user ID to headers for downstream use:
const response = NextResponse.next()
response.headers.set("x-user-id", payload.sub as string)
return response
} catch {
return NextResponse.redirect(new URL("/login", req.url))
}
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/api/protected/:path*"],
}
Authelia
Authelia — self-hosted SSO portal:
Docker Compose setup
# docker-compose.yml
version: "3.8"
services:
authelia:
image: authelia/authelia:latest
container_name: authelia
volumes:
- ./authelia/configuration.yml:/config/configuration.yml
- ./authelia/users_database.yml:/config/users_database.yml
ports:
- "9091:9091"
environment:
TZ: "America/Los_Angeles"
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: authelia-redis
volumes:
- redis-data:/data
restart: unless-stopped
# Reverse proxy (Traefik, nginx, Caddy, etc.)
traefik:
image: traefik:v3
container_name: traefik
command:
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
volumes:
redis-data:
Configuration
# authelia/configuration.yml
server:
address: "tcp://0.0.0.0:9091/"
log:
level: info
jwt_secret: your-jwt-secret-here
authentication_backend:
file:
path: /config/users_database.yml
password:
algorithm: argon2id
iterations: 3
memory: 65536
parallelism: 4
salt_length: 16
# Or use LDAP:
# ldap:
# address: ldap://openldap:389
# base_dn: dc=example,dc=com
# users_filter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))"
# groups_filter: "(&(member={dn})(objectClass=groupOfNames))"
session:
secret: your-session-secret
cookies:
- domain: example.com
authelia_url: "https://auth.example.com"
redis:
host: redis
port: 6379
storage:
local:
path: /config/db.sqlite3
# Or PostgreSQL:
# postgres:
# address: tcp://postgres:5432
# database: authelia
# username: authelia
# password: your-db-password
notifier:
smtp:
address: "submissions://smtp.gmail.com:465"
username: "your@gmail.com"
password: "your-app-password"
sender: "Authelia <auth@example.com>"
# Or filesystem (development):
# filesystem:
# filename: /config/notification.txt
totp:
issuer: example.com
period: 30
digits: 6
webauthn:
display_name: Example
attestation_conveyance_preference: indirect
user_verification: preferred
timeout: 60s
access_control:
default_policy: deny
rules:
# Public access:
- domain: "public.example.com"
policy: bypass
# Single-factor auth:
- domain: "internal.example.com"
policy: one_factor
# Two-factor required:
- domain: "secure.example.com"
policy: two_factor
# Group-based access:
- domain: "admin.example.com"
policy: two_factor
subject:
- "group:admins"
# API bypass for service accounts:
- domain: "api.example.com"
resources:
- "^/health$"
policy: bypass
# Network-based rules:
- domain: "*.example.com"
networks:
- "10.0.0.0/8"
policy: one_factor
Users database
# authelia/users_database.yml
users:
admin:
displayname: "Admin User"
password: "$argon2id$v=19$m=65536,t=3,p=4$..." # Generate with: authelia crypto hash generate argon2
email: admin@example.com
groups:
- admins
- developers
developer:
displayname: "Developer"
password: "$argon2id$v=19$m=65536,t=3,p=4$..."
email: dev@example.com
groups:
- developers
viewer:
displayname: "Viewer"
password: "$argon2id$v=19$m=65536,t=3,p=4$..."
email: viewer@example.com
groups:
- viewers
Reverse proxy integration (Traefik)
# Protect services with Authelia via Traefik labels:
services:
my-app:
image: my-app:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls=true"
- "traefik.http.routers.myapp.middlewares=authelia@docker"
authelia:
image: authelia/authelia:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`auth.example.com`)"
- "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls=true"
- "traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth"
- "traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Email,Remote-Name"
// Read Authelia headers in your app (behind reverse proxy):
import express from "express"
const app = express()
app.get("/api/profile", (req, res) => {
// Authelia sets these headers via forward auth:
const user = req.headers["remote-user"] as string
const email = req.headers["remote-email"] as string
const groups = (req.headers["remote-groups"] as string)?.split(",") ?? []
const name = req.headers["remote-name"] as string
if (!user) {
return res.status(401).json({ error: "Not authenticated" })
}
res.json({ user, email, groups, name })
})
// Role-based middleware:
function requireGroup(group: string) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const groups = (req.headers["remote-groups"] as string)?.split(",") ?? []
if (!groups.includes(group)) {
return res.status(403).json({ error: "Insufficient permissions" })
}
next()
}
}
app.delete("/api/admin/users/:id", requireGroup("admins"), async (req, res) => {
await deleteUser(req.params.id)
res.json({ deleted: true })
})
Feature Comparison
| Feature | SuperTokens | Hanko | Authelia |
|---|---|---|---|
| Type | Auth SDK | Auth service | SSO gateway |
| Email/password | ✅ | ✅ | ✅ (file/LDAP) |
| Passwordless | ✅ (OTP + magic link) | ✅ (passkeys + email) | ❌ |
| Passkeys/WebAuthn | ❌ | ✅ (native) | ✅ (2FA) |
| Social login | ✅ (Google, GitHub, etc.) | ✅ (via config) | ❌ |
| TOTP (2FA) | ✅ | ❌ | ✅ |
| SSO | Enterprise plan | ❌ | ✅ (core feature) |
| LDAP/AD | ❌ | ❌ | ✅ |
| Session management | ✅ (SDK) | ✅ (JWT) | ✅ (Redis) |
| Pre-built UI | ✅ (React) | ✅ (web components) | ✅ (portal) |
| Framework SDKs | Node, React, Python, Go | JS (framework-agnostic) | Reverse proxy headers |
| User management | ✅ (dashboard) | ✅ (admin API) | ✅ (file/LDAP) |
| Multi-tenancy | ✅ | ❌ | Via access rules |
| Role-based access | ✅ (built-in) | ❌ | ✅ (groups + rules) |
| Self-hosted | ✅ (Docker) | ✅ (Docker) | ✅ (Docker) |
| Managed cloud | ✅ | ✅ | ❌ |
| Language | Node.js/Python/Go | Go | Go |
When to Use Each
Use SuperTokens if:
- Need a complete authentication SDK integrated directly into your app
- Want email/password, social login, passwordless, and session management in one package
- Building a multi-tenant SaaS with role-based access control
- Prefer pre-built React UI components with deep customization
Use Hanko if:
- Want passkey-first, passwordless authentication
- Need framework-agnostic web components that drop into any frontend
- Building a modern app where WebAuthn/FIDO2 is the primary auth method
- Prefer minimal auth UI that works across React, Vue, Svelte, vanilla JS
Use Authelia if:
- Need a centralized SSO portal for multiple services behind a reverse proxy
- Want two-factor authentication (TOTP + WebAuthn) for all your apps
- Building infrastructure where LDAP/Active Directory integration is required
- Prefer policy-based access control with domain-level security rules
Methodology
Download data from npm registry and GitHub (March 2026). Feature comparison based on supertokens-node v21.x, @teamhanko/hanko-elements v1.x, and Authelia v4.x.
Compare authentication tools and developer security on PkgPulse →