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 |
Evaluating Self-Hosted Auth Complexity
Self-hosted authentication is operationally more complex than managed auth services. You own the uptime, the database backups, the version upgrades, and the security patches. If the auth service goes down, your application cannot authenticate users — auth becomes a critical infrastructure dependency requiring the same uptime investment as your primary database. Before choosing a self-hosted auth platform, honestly assess your team's infrastructure operational capacity: do you have on-call rotation, monitoring alerts, and runbook documentation for authentication service incidents? For most early-stage products, managed auth services like Clerk, Auth0, or WorkOS provide better risk-adjusted outcomes. Self-hosted auth makes economic and compliance sense for organizations with data residency requirements, large user bases where per-MAU pricing is prohibitive, or strict vendor dependency policies.
Security Hardening in Production
Self-hosted authentication requires security hardening that managed auth services handle automatically. SuperTokens' core service must be deployed with network access restricted to your application servers — never expose the SuperTokens core port (3567) publicly. Use a reverse proxy or firewall rule to allow only your API service's IP to connect. Hanko's backend similarly should run behind a reverse proxy with HTTPS termination; the Docker container itself serves HTTP and relies on the proxy for TLS. Authelia's forward auth model means the authentication check happens at the reverse proxy level before requests reach your application — ensure your application containers reject requests that don't include Authelia's Remote-User header, preventing direct access that bypasses the auth layer.
Session Management and Token Rotation
SuperTokens implements refresh token rotation by default — each time the access token is refreshed, a new refresh token is issued and the old one is invalidated. This prevents refresh token theft from replaying a stolen token after the victim has used it. The access token lifetime (default 1 hour) and refresh token lifetime (default 100 days) should be tuned to your security requirements: shorter access token lifetimes reduce the exposure window from a stolen token but increase token refresh traffic. Hanko uses JWTs with JWKS-based verification — the JWT expiry is enforced by the middleware, but Hanko's server must be reachable to validate the public key set. Cache the JWKS response aggressively (with a short background refresh) to avoid authentication latency on every request.
TypeScript Integration
SuperTokens ships TypeScript declarations for all its Node.js recipe packages, with the recipe override pattern requiring type-casting the original implementation's methods. The overrideGlobalClaimValidators function for role-based access control is fully typed when using the UserRoles.UserRoleClaim.validators API. Hanko's @teamhanko/hanko-elements package includes TypeScript declarations for the SDK methods but the web components (<hanko-auth>, <hanko-profile>) require global type augmentation in JSX environments — declare the custom elements in a types/hanko.d.ts file using the standard IntrinsicElements interface extension pattern. For Authelia, there is no SDK — TypeScript integration means typing the Remote-User, Remote-Groups, and Remote-Email headers in your Express request type augmentation.
Passkey Adoption and WebAuthn Production Considerations
Hanko's passkey-first approach aligns with 2026's accelerating WebAuthn adoption, but passkeys have specific production requirements. The Relying Party ID (rpId in WebAuthn terminology) must match the origin's domain exactly — a passkey registered on auth.example.com cannot be used on app.example.com even if both are subdomains. Plan your domain structure before deploying Hanko in production, as changing the rpId after users have registered passkeys invalidates all existing credentials. For cross-device passkey scenarios (registering on desktop, using on mobile), ensure your Hanko deployment is reachable from the same domain on both devices. Test passkey flows on iOS, Android, Windows Hello, and macOS Touch ID before launch — the user experience and error messages vary significantly across platforms.
Scaling and High Availability
SuperTokens' core service is stateless between requests (session data is stored in the connected database) and can be horizontally scaled behind a load balancer. The core requires a database connection — PostgreSQL or MySQL — and uses connection pooling internally. For high availability, deploy at least two SuperTokens core instances and use a load balancer with health checks. Authelia stores session state in Redis and benefits from Redis Cluster or Redis Sentinel for HA. The Redis session store means Authelia instances are stateless and can be scaled horizontally. Hanko's stateless JWT approach means the backend service itself is easy to scale — the state is entirely in the database and the browser's passkey store.
Incident Response and Account Recovery
When authentication infrastructure fails, account recovery procedures determine how quickly users regain access. SuperTokens's email-based password reset and OTP delivery provide multiple recovery paths, but they depend on reliable email delivery — ensure your email provider (Resend, Postmark, SES) has high deliverability and monitor bounce rates. SuperTokens supports custom recovery flows via hooks, enabling you to add support-assisted account recovery where an admin can reset a user's session. Hanko's passkey-first approach creates a recovery challenge: if a user loses all their registered devices, passkey recovery requires enrolling a new device through a fallback authentication factor (email OTP). Document the account recovery procedure clearly in your app's UI, since users are unfamiliar with passkey recovery compared to password resets. Authelia's recovery path is tied to the second factor — if a user loses their TOTP device, an admin must disable their second factor through Authelia's configuration or bypass mechanism, which requires administrative access to the Authelia instance.
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 →
See also: AVA vs Jest and bcrypt vs argon2 vs scrypt: Password Hashing in 2026, Cerbos vs Permit.io vs OPA (2026).