How to Self-Host Hanko: Open Source Passkey Auth 2026
How to Self-Host Hanko: Open Source Passkey Authentication in 2026
TL;DR
Hanko is an open source authentication server built from the ground up for passkeys — WebAuthn credentials tied to biometrics (Face ID, Touch ID, Windows Hello) that replace passwords entirely. Auth0 charges $240+/month for 1,000 MAU with MFA; Clerk charges $25+/month for basic features. Hanko's self-hosted version is free and supports passkeys, TOTP (Google Authenticator), email magic links, OAuth (Google, Apple, GitHub), and SAML — with a drop-in <hanko-auth> web component that works in any framework. The hosted cloud free tier covers 10,000 MAU. Self-hosting on a VPS gives you the same features for essentially the cost of the server.
Key Takeaways
- Passkey-first: Built for WebAuthn — Face ID, Touch ID, Windows Hello replace passwords by default
- Drop-in web component:
<hanko-auth>and<hanko-profile>work in React, Vue, Svelte, vanilla JS - Multiple auth methods: Passkeys, TOTP 2FA, email magic links, OAuth (Google, Apple, GitHub, Microsoft), SAML/OIDC
- JWT-based sessions: Hanko issues JWTs; your backend validates them with a public JWKS endpoint
- Self-hosted free: unlimited users; no MAU pricing on self-hosted
- GitHub stars: 8,000+ with active development
- License: AGPL v3 (community) / commercial (enterprise features)
Why Passkeys Over Passwords?
Traditional password auth creates risk at every step: users reuse passwords, phishing captures credentials, databases leak hashes. Passkeys eliminate these vectors:
- No shared secret: The server never stores a password — it stores a public key. Even if your database leaks, no credentials are exposed.
- Phishing-resistant: Passkeys are domain-scoped. A credential created for
app.example.comcannot be used onevil-example.com. Phishing attacks become impossible. - Biometric convenience: Users authenticate with their fingerprint, face, or device PIN — no typing, no remembering.
- Cross-device via iCloud Keychain / Google Password Manager: Passkeys sync automatically across a user's Apple or Android devices.
In 2026, passkey adoption has crossed the mainstream threshold — Apple, Google, Microsoft, and GitHub all support them. The infrastructure is here.
Self-Hosting with Docker Compose
Prerequisites
- Docker and Docker Compose
- PostgreSQL or SQLite database
- Domain with HTTPS (required — WebAuthn won't work without TLS)
docker-compose.yml
version: "3.8"
services:
hanko:
image: ghcr.io/teamhanko/hanko:latest
container_name: hanko
restart: unless-stopped
command: serve all
ports:
- "8000:8000" # Public API (auth flows, frontend)
- "8001:8001" # Admin API (user management — keep private!)
environment:
CONFIG_FILE: /etc/hanko/config.yaml
volumes:
- ./hanko-config.yaml:/etc/hanko/config.yaml:ro
depends_on:
- db
db:
image: postgres:15-alpine
container_name: hanko-db
restart: unless-stopped
environment:
POSTGRES_DB: hanko
POSTGRES_USER: hanko
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
hanko-config.yaml
# hanko-config.yaml
server:
public:
address: ":8000"
admin:
address: ":8001"
database:
host: db
port: 5432
database: hanko
user: hanko
password: YOUR_DB_PASSWORD_HERE
dialect: postgres
secrets:
keys:
- CHANGE-THIS-32-CHAR-SECRET-KEY!!
# Passkeys configuration
webauthn:
relying_party:
id: yourdomain.com # Your domain (not subdomain)
display_name: "My App"
origins:
- https://app.yourdomain.com # Allowed origins for WebAuthn
# Email for magic links and notifications
email:
from_address: auth@yourdomain.com
from_name: "My App Auth"
smtp:
host: smtp.youremail.com
port: 587
user: auth@yourdomain.com
password: YOUR_SMTP_PASSWORD
tls:
enabled: true
# Optional: Enable OAuth providers
third_party:
providers:
google:
enabled: true
client_id: YOUR_GOOGLE_CLIENT_ID
secret: YOUR_GOOGLE_CLIENT_SECRET
github:
enabled: true
client_id: YOUR_GITHUB_CLIENT_ID
secret: YOUR_GITHUB_CLIENT_SECRET
redirect_url: https://app.yourdomain.com/auth/callback
# Session configuration
session:
lifespan: "12h"
cookie:
same_site: strict
secure: true
http_only: true
# Password auth (disabled by default — passkey-first)
password:
enabled: false
min_password_length: 12
Generate the Secret Key
openssl rand -hex 16 # 32 chars for the secrets.keys entry
Start Hanko
docker compose up -d
# Check Hanko started correctly
docker compose logs hanko | head -20
# Should see: "Starting Hanko API server on :8000"
# Run database migrations
docker compose exec hanko hanko migrate up
# Hanko public API: http://localhost:8000
# Hanko admin API: http://localhost:8001 (restrict access!)
Nginx Reverse Proxy with TLS
# /etc/nginx/sites-available/hanko
server {
listen 443 ssl http2;
server_name auth.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/auth.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.yourdomain.com/privkey.pem;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
# Required for passkey flows
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
server {
# Admin API — restrict to internal network or VPN
listen 127.0.0.1:8001;
server_name localhost;
location / {
proxy_pass http://localhost:8001;
}
}
certbot --nginx -d auth.yourdomain.com
systemctl reload nginx
Frontend Integration: The <hanko-auth> Web Component
Hanko ships a web component that renders the entire auth UI — no custom UI needed:
React Integration
npm install @teamhanko/hanko-elements @teamhanko/hanko-frontend-sdk
// components/HankoAuth.tsx
import { useEffect } from 'react'
import { register } from '@teamhanko/hanko-elements'
const HANKO_API_URL = process.env.NEXT_PUBLIC_HANKO_API_URL!
// e.g., https://auth.yourdomain.com
export default function HankoAuth() {
useEffect(() => {
register(HANKO_API_URL).catch(console.error)
}, [])
return (
<hanko-auth
api={HANKO_API_URL}
/>
)
}
// pages/login.tsx
export default function LoginPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<HankoAuth />
</div>
)
}
The <hanko-auth> component handles everything: passkey registration, passkey authentication, email magic link fallback, and OAuth redirects. It adapts based on what the user's device supports — prompting for Touch ID on macOS, fingerprint on Android, etc.
Session Management with React
// hooks/useHanko.ts
import { useEffect, useState } from 'react'
import { Hanko } from '@teamhanko/hanko-frontend-sdk'
const hanko = new Hanko(process.env.NEXT_PUBLIC_HANKO_API_URL!)
export function useAuth() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Check current session
hanko.user.getCurrent()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false))
// Listen for auth events
hanko.onAuthFlowCompleted(() => {
hanko.user.getCurrent().then(setUser)
})
}, [])
const logout = () => hanko.user.logout()
return { user, loading, logout }
}
// Profile management page
export function ProfilePage() {
const { user, logout } = useAuth()
useEffect(() => {
import('@teamhanko/hanko-elements').then(({ register }) =>
register(process.env.NEXT_PUBLIC_HANKO_API_URL!)
)
}, [])
return (
<div>
<p>Logged in as: {user?.email}</p>
{/* Profile component for managing passkeys and sessions */}
<hanko-profile api={process.env.NEXT_PUBLIC_HANKO_API_URL!} />
<button onClick={logout}>Sign Out</button>
</div>
)
}
Backend JWT Validation
Hanko issues JWTs after authentication. Your backend validates them against Hanko's JWKS endpoint:
Node.js / Express Backend
// middleware/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose'
const HANKO_API_URL = process.env.HANKO_API_URL!
const JWKS = createRemoteJWKSet(
new URL(`${HANKO_API_URL}/.well-known/jwks.json`)
)
export async function requireAuth(req, res, next) {
const token = req.cookies.hanko // Hanko sets a 'hanko' cookie
if (!token) {
return res.status(401).json({ error: 'Not authenticated' })
}
try {
const { payload } = await jwtVerify(token, JWKS)
req.userId = payload.sub // Hanko user ID
req.userEmail = payload.email
next()
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' })
}
}
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.userId)
res.json(user)
})
Next.js Middleware
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL(`${process.env.HANKO_API_URL}/.well-known/jwks.json`)
)
export async function middleware(req: NextRequest) {
const hankoToken = req.cookies.get('hanko')?.value
if (!hankoToken) {
return NextResponse.redirect(new URL('/login', req.url))
}
try {
await jwtVerify(hankoToken, JWKS)
return NextResponse.next()
} catch {
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/api/protected/:path*'],
}
Admin API: User Management
The Hanko admin API (port 8001) lets you manage users programmatically:
# List users
curl http://localhost:8001/users?page=1&per_page=20
# Get user by email
curl "http://localhost:8001/users?email=alice@example.com"
# Delete a user
curl -X DELETE "http://localhost:8001/users/USER_ID"
# Force logout all sessions for a user (e.g., account compromise)
curl -X DELETE "http://localhost:8001/users/USER_ID/sessions"
Keep the admin API on a private network — never expose port 8001 to the internet.
Enabling TOTP (Google Authenticator)
For users on devices without passkey support, enable TOTP as a fallback:
# hanko-config.yaml — add to MFA section
mfa:
totp:
enabled: true
Users can enroll their authenticator app from the <hanko-profile> component. Hanko shows a QR code for scanning with Google Authenticator, Authy, or 1Password.
Email Magic Links (Passwordless Fallback)
For users who don't have passkey-capable devices, email magic links provide passwordless access:
# hanko-config.yaml
email_delivery:
enabled: true
passcode:
ttl: 300 # Magic link valid for 5 minutes
email:
use_for_authentication: true
Hanko automatically falls back to email magic links when:
- The user's device doesn't support WebAuthn
- The user is on a new device and hasn't registered a passkey yet
- The user explicitly chooses email login
Vue and Svelte Integration
Hanko's web components work in any framework — not just React:
Vue 3
// plugins/hanko.ts
import { register } from '@teamhanko/hanko-elements'
export default defineNuxtPlugin(async () => {
await register(process.env.NUXT_PUBLIC_HANKO_API_URL!)
})
// pages/login.vue
<template>
<div class="flex items-center justify-center min-h-screen">
<hanko-auth :api="hankoApiUrl" />
</div>
</template>
<script setup>
const hankoApiUrl = useRuntimeConfig().public.hankoApiUrl
// Handle successful auth
const hanko = useHanko()
onMounted(() => {
hanko.onAuthFlowCompleted(() => {
navigateTo('/dashboard')
})
})
</script>
Svelte / SvelteKit
// src/hooks.server.ts — SvelteKit route protection
import { createRemoteJWKSet, jwtVerify } from 'jose'
import type { Handle } from '@sveltejs/kit'
const JWKS = createRemoteJWKSet(
new URL(`${process.env.HANKO_API_URL}/.well-known/jwks.json`)
)
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('hanko')
if (token) {
try {
const { payload } = await jwtVerify(token, JWKS)
event.locals.userId = payload.sub as string
event.locals.userEmail = payload.email as string
} catch {
// Token invalid — clear and redirect
}
}
// Protect routes starting with /app
if (event.url.pathname.startsWith('/app') && !event.locals.userId) {
return Response.redirect(new URL('/login', event.url))
}
return resolve(event)
}
Troubleshooting Common Issues
"WebAuthn not available" errors
Passkeys require HTTPS — they will not work on http://localhost or plain HTTP. For local development:
# Option 1: Use localhost with a browser exception
# Chrome/Safari allow localhost as a secure origin for WebAuthn testing
# Option 2: Use a local HTTPS proxy
mkcert -install
mkcert localhost
caddy reverse-proxy --from localhost:443 --to localhost:8000 \
--tls-cert localhost.pem --tls-key localhost-key.pem
Passkey registration fails on Safari
Ensure the relying_party.id in your config matches your domain exactly (not the subdomain if you're using app.yourdomain.com):
webauthn:
relying_party:
id: yourdomain.com # NOT app.yourdomain.com
origins:
- https://app.yourdomain.com # But origin includes subdomain
Email magic links not arriving
Check SMTP configuration and test connectivity:
# Test SMTP from within the container
docker compose exec hanko \
curl --url "smtp://smtp.youremail.com:587" \
--ssl-reqd \
--mail-from "auth@yourdomain.com" \
--mail-rcpt "test@example.com" \
--user "auth@yourdomain.com:password" \
-T /dev/null
JWT validation errors in backend
Verify your backend is fetching the JWKS from the correct URL and that the hanko cookie is being sent with API requests (check CORS and cookie settings):
// Verify JWKS endpoint is accessible
const response = await fetch(`${process.env.HANKO_API_URL}/.well-known/jwks.json`)
const jwks = await response.json()
console.log('JWKS keys:', jwks.keys.length) // Should be > 0
Upgrading Hanko
cd /opt/hanko
# Pull the latest image
docker compose pull hanko
# Restart with new image
docker compose up -d hanko
# Run any new migrations
docker compose exec hanko hanko migrate up
# Check logs for issues
docker compose logs -f hanko
Hanko follows semantic versioning. Minor updates (1.x → 1.y) are safe to apply without data migration. Always review the changelog at github.com/teamhanko/hanko/releases before major version upgrades.
Backup and Restore
#!/bin/bash
# backup-hanko.sh
DATE=$(date +%Y%m%d_%H%M)
# Backup PostgreSQL (contains all users, credentials, sessions)
docker compose exec -T db pg_dump -U hanko hanko \
| gzip > /backups/hanko/db_${DATE}.sql.gz
# Upload to B2
rclone copy /backups/hanko/db_${DATE}.sql.gz b2:my-backups/hanko/
echo "Hanko backup complete: $DATE"
Critical note: Hanko's secrets.keys in the config file is used to sign JWTs. Back up your config file and store it securely — losing it requires regenerating all user sessions.
Cost Comparison: Auth0 vs Hanko Self-Hosted
| Plan | Auth0 | Clerk | Hanko Self-Hosted |
|---|---|---|---|
| 1,000 MAU | $240/month | $25/month | ~$6/month (VPS) |
| 10,000 MAU | $1,500/month | $200/month | ~$10/month (VPS) |
| 100,000 MAU | Custom/~$6K | Custom | ~$20/month (VPS) |
| Passkeys | ✅ (Enterprise) | ✅ | ✅ |
| SAML/OIDC | ✅ (Enterprise) | ✅ | ✅ |
| Data location | Auth0's cloud | Clerk's cloud | Your server |
At 10,000 MAU, self-hosted Hanko saves $1,490/month compared to Auth0 — a compelling ROI even accounting for operational overhead.
Methodology
- GitHub data from github.com/teamhanko/hanko, March 2026
- Pricing comparisons from Auth0, Clerk pricing pages, March 2026
- Setup guide based on Hanko official documentation (docs.hanko.io)
- Version: Hanko 1.x (check GitHub releases for latest)
Compare open source authentication alternatives on OSSAlt — self-hosting difficulty, passkey support, and security features.
Related: Best Open Source Auth0 Alternatives 2026 · How to Self-Host Authentik: SSO and Identity Provider 2026 · Keycloak vs Authentik vs Hanko: Open Source Auth Compared 2026