Skip to main content

SuperTokens vs Hanko vs Authelia: Self-Hosted Authentication (2026)

·PkgPulse Team

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

FeatureSuperTokensHankoAuthelia
TypeAuth SDKAuth serviceSSO gateway
Email/password✅ (file/LDAP)
Passwordless✅ (OTP + magic link)✅ (passkeys + email)
Passkeys/WebAuthn✅ (native)✅ (2FA)
Social login✅ (Google, GitHub, etc.)✅ (via config)
TOTP (2FA)
SSOEnterprise plan✅ (core feature)
LDAP/AD
Session management✅ (SDK)✅ (JWT)✅ (Redis)
Pre-built UI✅ (React)✅ (web components)✅ (portal)
Framework SDKsNode, React, Python, GoJS (framework-agnostic)Reverse proxy headers
User management✅ (dashboard)✅ (admin API)✅ (file/LDAP)
Multi-tenancyVia access rules
Role-based access✅ (built-in)✅ (groups + rules)
Self-hosted✅ (Docker)✅ (Docker)✅ (Docker)
Managed cloud
LanguageNode.js/Python/GoGoGo

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 →

Comments

Stay Updated

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