Hanko 3.1.2
Overview
Hanko is an open-source authentication server. Your web or mobile application hands sign-up and sign-in to Hanko. Users register and log in with passkeys, e-mail passcodes, passwords, optional TOTP and security-key MFA, and social or SAML sign-in that you configure.
Hanko returns a session JWT that your backend verifies against Hanko's public keys (/.well-known/jwks.json). You can put Hanko's ready-made web components (<hanko-auth>, <hanko-profile>) or your own UI on top of its Flow API. You manage users, webhooks and audit logs through its admin API. The image is the Hanko backend only and has no web console of its own.
This guide covers launching and using the Hanko 3.1.2 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud identity, secrets and PKI listing on AWS Marketplace. You pick it as the software version when you subscribe and launch.
What you get:
- A SecureAnyCloud build of the upstream 3.1.2 release, built from the upstream backend/v3.1.2 tag with Go 1.26.10 and in-range dependency security updates (labstack/echo 4.16.2, go-jose 4.1.6, golang.org/x/net 0.62.0). No Hanko source file is changed. The exact source, build record and licence are on the image in /usr/share/doc/sac-hanko/.
- HTTPS from the first boot. nginx is the only public listener (ports 80 and 443). It serves a certificate generated on your instance (an end-entity certificate, not a certificate authority), and port 80 redirects to HTTPS. First boot sets the public address from the instance's EC2 public DNS name, and you can change it to your own domain. That name is also Hanko's WebAuthn relying party, its allowed browser origin and the issuer of its JWTs.
- The admin API is not on the internet. Upstream Hanko's admin API has no authentication of its own. Here it listens on
127.0.0.1only, and an nftables rule lets only nginx and root connect to its port. nginx serves it onhttps://127.0.0.1:8444with a per-instance admin password. You reach it through an SSH tunnel or withsudo hanko-adminon the instance. Hanko's tenant management API is not started at all. - No default login, no shared secrets. Hanko has no administrator account. Before anything is reachable, first boot creates the PostgreSQL cluster and mints this instance's Hanko secret key and the admin API password. The secret key encrypts the JWT signing keys that Hanko generates in your empty database.
- PostgreSQL with no password at all. PostgreSQL 16 has no TCP listener. Hanko connects over its Unix socket with peer authentication, so no database password exists.
- Real client addresses. nginx hands Hanko the real TCP peer, never a client's own
X-Forwarded-FororX-Real-IP, and Hanko trusts that header only from nginx. Hanko's password and passcode rate limits and nginx's per-client limits therefore apply to each real client address. - Metadata access blocked. Hanko calls URLs that you configure (webhooks, OpenID Connect and SAML identity providers). Every service is blocked from the instance metadata service, and the AMI requires IMDSv2.
- Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
- On the image: Hanko 3.1.2 (backend), nginx, PostgreSQL 16, nftables, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
- Defaults: default user
ubuntu, architecture x86_64, recommended instance type m5.large. - 24/7 SecureAnyCloud support
Prerequisites
You need:
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet. Hanko needs outbound HTTPS for an SMTP server, webhooks and identity providers that you add.
- A security group that allows TCP 22 (SSH, also used for the admin API tunnel) from your own networks only. It must also allow TCP 443 (the authentication API) and TCP 80 (redirects to HTTPS) from your users. Never open 8000, 8001, 8444 or 5432, because they listen on the loopback address only, or not at all.
- For production, a DNS name you control (for example
auth.example.com), a certificate from a certificate authority for that name, and an SMTP server for e-mail passcodes.
Launch the instance
In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick Hanko 3.1.2 on Ubuntu 24.04 as the software version, choose your Region, then choose Launch through EC2.
Choose m5.large, your key pair and a security group as described above.
To launch with the AWS CLI instead, replace the values in angle brackets:
aws ec2 run-instances --image-id <ami-id-from-the-listing> --instance-type m5.large \
--key-name <your-key-pair> --security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=hanko}]'First boot takes about ten seconds after cloud-init has run.
To use your own name from the start, put it in /etc/sac/hanko-domain.preset with user data, for example a cloud-init write_files entry. Otherwise first boot uses the instance's public DNS name, or its private DNS name if it has no public one.
Connect to your instance
On Ubuntu 24.04, the SSH user is ubuntu. Connect over SSH:
ssh -i <your-key.pem> ubuntu@<public-ip>Check the services
systemctl is-active hanko-firstboot postgresql@16-main hanko-guard hanko hanko-admin nginxactive active active active active activeOnly SSH, HTTP and HTTPS are public. The Hanko public API (8000), its admin API (8001) and the admin API's HTTPS front (8444) are on the loopback address. PostgreSQL has no TCP listener at all.
Check the listeners:
sudo ss -tln | awk '{print $4}' | grep -E ':(22|80|443|5432|8000|8001|8002|8444)$' | sort -u0.0.0.0:22 0.0.0.0:443 0.0.0.0:80 127.0.0.1:8000 127.0.0.1:8001 127.0.0.1:8444 [::]:22 [::]:443 [::]:80curl -s http://127.0.0.1/health/ready; echo{"ready":true}Get your addresses and the admin password
sudo cat /root/hanko-credentials.txtThe file (root only, mode 0600) holds the public address, the JWKS URL, the SHA-256 fingerprint of the instance's certificate and the admin API password. This example shows the password as <redacted>:
# Hanko 3.1.2 - written on first boot by hanko-firstboot.service. Unique to this instance. Keep it secret. HANKO_URL=https://ec2-44-201-211-174.compute-1.amazonaws.com HANKO_JWKS_URL=https://ec2-44-201-211-174.compute-1.amazonaws.com/.well-known/jwks.json HANKO_TLS_SHA256=1E:CA:4A:42:E1:AD:B1:6B:B6:7A:21:0A:88:EE:34:EA:78:4D:64:34:D5:D1:AC:25:AF:D5:18:AC:9A:D6:0E:F9 HANKO_ADMIN_API_VIA_SSH_TUNNEL=https://127.0.0.1:8444 HANKO_ADMIN_USER=admin HANKO_ADMIN_PASSWORD=<redacted>
#
# The public authentication API (sign-up, sign-in, sessions, JWKS) is served at HANKO_URL. Point your app's Hanko # frontend (the hanko-elements <hanko-auth> web component or the frontend SDK) at it, and add your app's origin to
# cors.allow_origins in /usr/local/lib/sac/hanko/config.yaml.tmpl (then:
sudo hanko-set-domain <name>).
# The admin API (users, webhooks, audit logs) listens on 127.0.0.1:8444 only: open an SSH tunnel
# ssh -L 8444:127.0.0.1:8444 ubuntu@<this instance>
# and use HTTPS basic authentication with HANKO_ADMIN_USER / HANKO_ADMIN_PASSWORD, or run it here:
sudo hanko-admin GET /users
# The certificate is self-signed and unique to this instance: compare its SHA-256 fingerprint (above) with the one your
# client shows, or replace it with a CA-issued certificate for your own name.
# No SMTP server is configured: e-mail passcodes need SMTP or an email.send webhook (see the guide).
# Your own name (point DNS at this instance first; existing passkeys stop working):
sudo hanko-set-domain auth.example.com
# User guide: https://secureanycloud.com/stacks/hanko-3-1-2/To show only the non-secret part, which is safe in a shared terminal:
sudo grep -E '^HANKO_(URL|JWKS_URL|TLS_SHA256|ADMIN_API_VIA_SSH_TUNNEL|ADMIN_USER)=' /root/hanko-credentials.txtHANKO_URL=https://ec2-44-201-211-174.compute-1.amazonaws.com HANKO_JWKS_URL=https://ec2-44-201-211-174.compute-1.amazonaws.com/.well-known/jwks.json HANKO_TLS_SHA256=1E:CA:4A:42:E1:AD:B1:6B:B6:7A:21:0A:88:EE:34:EA:78:4D:64:34:D5:D1:AC:25:AF:D5:18:AC:9A:D6:0E:F9 HANKO_ADMIN_API_VIA_SSH_TUNNEL=https://127.0.0.1:8444 HANKO_ADMIN_USER=adminRun the self-test
hanko-selftest exercises the whole path over HTTPS on your instance. It runs these steps:
- Creates a throwaway user through the admin API.
- Signs the user in through the public Flow API (e-mail, then password).
- Verifies the RS256 signature of the session JWT that Hanko issues against this instance's JWKS.
- Checks that a wrong password is refused.
- Deletes the user again.
Run it:
sudo hanko-selftestsign-in OK: JWT RS256 kid 229e0af7 verifies against https://ec2-44-201-211-174.compute-1.amazonaws.com/.well-known/jwks.json; wrong password refused HANKO_SELFTEST_OKThe keys your backend verifies sessions with
Hanko signs every session JWT with a 4096-bit RSA key that it generated on your instance's first boot. Your backend fetches the public half from the JWKS endpoint. Cache it, because the kid in a JWT's header picks the key:
D=$(cat /etc/sac/hanko-domain)
curl -s -m 15 --cacert /etc/ssl/hanko/hanko.crt --resolve "$D:443:127.0.0.1" "https://$D/.well-known/jwks.json" | jq -c '.keys[] | {kid, kty, alg, use}'{"kid":"229e0af7-b837-4f72-b381-6b5db2a2121c","kty":"RSA","alg":"RS256","use":"sig"}The certificate is self-signed for this instance. From your workstation, compare its fingerprint with HANKO_TLS_SHA256 before you trust it. Alternatively, replace it with a CA-issued certificate (Step 10):
D=$(cat /etc/sac/hanko-domain)
echo | timeout 15 openssl s_client -connect 127.0.0.1:443 -servername "$D" 2>/dev/null | openssl x509 -noout -fingerprint -sha256 sudo grep '^HANKO_TLS_SHA256=' /root/hanko-credentials.txtsha256 Fingerprint=1E:CA:4A:42:E1:AD:B1:6B:B6:7A:21:0A:88:EE:34:EA:78:4D:64:34:D5:D1:AC:25:AF:D5:18:AC:9A:D6:0E:F9 HANKO_TLS_SHA256=1E:CA:4A:42:E1:AD:B1:6B:B6:7A:21:0A:88:EE:34:EA:78:4D:64:34:D5:D1:AC:25:AF:D5:18:AC:9A:D6:0E:F9Manage users with the admin API
On the instance, sudo hanko-admin METHOD PATH [JSON] calls the admin API through nginx with the per-instance password. It never puts the password on a command line.
List users, create one with a verified e-mail address, and delete it again:
sudo hanko-admin GET /users | jq length
ID=$(sudo hanko-admin POST /users '{"emails":[{"address":"jane@example.com","is_primary":true,"is_verified":true}]}' | jq -r .id)
sudo hanko-admin GET "/users/$ID" | jq -c '{id, emails: [.emails[].address]}'
sudo hanko-admin DELETE "/users/$ID" >/dev/null && echo "deleted $ID"0 {"id":"ed594947-accf-433d-a9db-9ebd4494c6f6","emails":["jane@example.com"]} deleted ed594947-accf-433d-a9db-9ebd4494c6f6To give a user a password, for example during a migration, write the JSON to a root-only file. This keeps the password out of your shell history and the sudo log. Then pass the file with @:
umask 077; read -rs -p 'New password: ' PW; echo printf '{"password":"%s"}\n' "$PW" | sudo tee /root/pw.json >/dev/null; unset PW
sudo hanko-admin POST /users/<user-id>/password @/root/pw.json;
sudo shred -u /root/pw.jsonFrom your workstation, open an SSH tunnel and use the admin API with HTTPS basic authentication (user admin, and the password from Step 4). curl asks for the password:
ssh -i <your-key.pem> -N -L 8444:127.0.0.1:8444 ubuntu@<public-ip> curl --cacert hanko.crt -u admin https://127.0.0.1:8444/usershanko.crt is a copy of /etc/ssl/hanko/hanko.crt from the instance. The admin API refuses requests that have no password, a wrong password or default credentials:
curl -s -o /dev/null -w 'no password: %{http_code}\n' --cacert /etc/ssl/hanko/hanko.crt https://127.0.0.1:8444/users
curl -s -o /dev/null -w 'admin:admin: %{http_code}\n' --cacert /etc/ssl/hanko/hanko.crt -u admin:admin https://127.0.0.1:8444/users
curl -s -o /dev/null -w 'straight to the admin API port as ubuntu: %{http_code}\n' http://127.0.0.1:8001/users || trueno password: 401 admin:admin: 401 straight to the admin API port as ubuntu: 000The full admin API covers users, e-mails, passwords, passkeys, sessions, organizations and roles, webhooks and audit logs. It is documented at https://docs.hanko.io/api-reference/admin.
Connect your application
Add your web app's origin to the cors.allow_origins list in /usr/local/lib/sac/hanko/config.yaml.tmpl. Then re-render the configuration, which also restarts Hanko:
bash sudo nano /usr/local/lib/sac/hanko/config.yaml.tmpl # add e.g. - "https://your-app.example.com" under cors.allow_origins sudo hanko-set-domain "$(cat /etc/sac/hanko-domain)"
Embed Hanko's web components (the MIT-licensed @teamhanko/hanko-elements package) in your app, and point them at your instance:
html <script type="module"> import { register } from "https://esm.run/@teamhanko/hanko-elements"; await register("https://<your-hanko-name>"); </script> <hanko-auth></hanko-auth>
On your backend, read the hanko cookie (or the Authorization: Bearer header that you forward). Verify the JWT with the keys from https://<your-hanko-name>/.well-known/jwks.json. Use algorithm RS256, issuer https://<your-hanko-name> and the same name as the audience.
GET /sessions/validate on the public API also validates a session for you.
Passkeys are bound to the relying party name. Use your final domain (Step 10) before your users register passkeys.
E-mail passcodes (SMTP or a webhook)
Sign-up by e-mail needs the passcode that Hanko sends to the address. The image ships without an SMTP server. Until you configure one, a sign-up stops at the passcode step, and no passcode is sent anywhere or written to a log. Sign-in by password works at once, as does sign-in for users you create through the admin API.
Either configure SMTP in /usr/local/lib/sac/hanko/config.yaml.tmpl and re-render as in Step 8:
email_delivery: enabled: true from_address: "auth@example.com" from_name: "Example" smtp: host: "smtp.example.com" port: "587" user: "auth@example.com" password: "<smtp-password>"Or let your own service deliver the e-mails. Subscribe a webhook to the email.send event. Hanko then POSTs every message, with the passcode, to the webhook as a JWT signed with the same keys as the sessions:
sudo hanko-admin POST /webhooks '{"callback":"https://your-app.example.com/hanko/email","events":["email.send"]}'Use your own domain and certificate
Point a DNS record at the instance, then run:
sudo hanko-set-domain auth.example.comThis re-renders nginx and Hanko for the new name (WebAuthn relying party, browser origin and JWT issuer) and makes a new self-signed certificate for it. Passkeys registered under the old name stop working.
Replace /etc/ssl/hanko/hanko.crt and hanko.key with a CA-issued certificate for the name. Then run sudo systemctl reload nginx.
Check the security posture
Hanko's admin API port accepts only nginx and root, even if you restart or reload nftables. Your own login cannot reach it, and nothing answers on the management port:
sudo nft list table inet sac_hanko_guard | grep -o 'tcp dport [0-9]* .*reject.*' | sed 's/ counter packets [0-9]* bytes [0-9]*//'
sudo systemctl restart nftables && sleep 5 curl -s -m 5 -o /dev/null -w 'admin API port as ubuntu, after an nftables restart: %{http_code}\n' http://127.0.0.1:8001/health/ready || true curl -s -m 5 -o /dev/null -w 'management port 8002: %{http_code}\n' http://127.0.0.1:8002/ || truetcp dport 8001 meta skuid != { 0, 33 } reject with tcp reset tcp dport 8000 meta skuid != { 0, 33, 997 } reject with tcp reset tcp dport 8002 reject with tcp reset admin API port as ubuntu, after an nftables restart: 000 management port 8002: 000Every service is blocked from the instance metadata service, and core dumps are off:
systemctl show -p IPAddressDeny -p LimitCORE hanko hanko-admin nginx postgresql@16-main hanko-firstbootIPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128 LimitCORE=0 IPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128 LimitCORE=0 IPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128 LimitCORE=0 IPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128 LimitCORE=0 IPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128 LimitCORE=0PostgreSQL has no role with a password and no TCP listener. Only the hanko service account may use the hanko database, and your own login is refused:
sudo -u postgres psql -X -tA -c "SELECT count(*) AS roles_with_a_password FROM pg_authid WHERE rolpassword IS NOT NULL"
sudo -u postgres psql -X -tA -c "SHOW listen_addresses" | sed 's/^$/(no TCP listener)/'
sudo -u ubuntu psql -X -h /var/run/postgresql -U hanko -d hanko -c 'SELECT 1' 2>&1 | grep -o 'Peer authentication failed for user "hanko"' || true0 (no TCP listener) Peer authentication failed for user "hanko"nginx refuses old TLS versions and unknown host names, and port 80 only redirects:
D=$(cat /etc/sac/hanko-domain);
IP=$(hostname -I | awk '{print $1}')
echo | timeout 15 openssl s_client -connect "$IP:443" -servername "$D" -tls1_1 -cipher 'DEFAULT:@SECLEVEL=0' 2>&1 | grep -m1 -oE 'alert protocol version|no protocols available' curl -s -m 15 -o /dev/null -w 'port 80: %{http_code}\n' --resolve "$D:80:$IP" "http://$D/login" curl -sk -m 15 -o /dev/null -w 'unknown Host on 443: %{http_code}\n' -H 'Host: evil.example' "https://$IP/health/ready" || truealert protocol version port 80: 301 unknown Host on 443: 000Passwords and secrets
/etc/hanko/secret-key(root only) is the key that encrypts Hanko's JWT signing keys in the database. Back it up with the database. Never change it on a running installation, or Hanko can no longer read its signing keys.- The admin API password is in
/root/hanko-credentials.txt. nginx stores only its SHA-512 crypt hash in/etc/nginx/sac/hanko-admin.htpasswd. To change the password, write a new hash there (for exampleprintf '%s' "$NEW" | openssl passwd -6 -stdin), update the credentials file, and runsudo systemctl reload nginx. - There is no database password. Hanko uses peer authentication on the Unix socket.
Your data and backups
Everything Hanko knows is in the PostgreSQL database hanko. That includes users, credentials, sessions, webhooks, audit logs and its encrypted signing keys. Back it up together with /etc/hanko/secret-key:
sudo -u postgres pg_dump -Fc hanko | sudo tee /var/backups/hanko-$(date +%F).dump >/dev/null
sudo ls -l /var/backups/ | grep hanko-rw-r--r-- 1 root root 111177 Oct 30 22:08 hanko-2026-10-30.dumpLicences and source
Hanko's backend is licensed under the GNU Affero General Public License v3. People who use your Hanko server over a network are entitled to its corresponding source. That source is the upstream tag plus the two dependency files that SecureAnyCloud changed:
head -14 /usr/share/doc/sac-hanko/SOURCE.txt sha256sum /usr/local/bin/hankoHanko 3.1.2 - corresponding source (GNU Affero General Public License v3, section 13) This appliance runs the Hanko backend (github.com/teamhanko/hanko, the "backend" module, AGPL-3.0-only, (c) Hanko GmbH). Users who interact with it over a network are entitled to its corresponding source. It is: * the upstream source at tag backend/v3.1.2 (commit 451e0892df9085efa2917305ddf2e952ea483652): https://github.com/teamhanko/hanko/tree/backend/v3.1.2/backend * with exactly two files replaced: backend/go.mod and backend/go.sum, as shipped in this directory (go.mod, go.sum). They raise dependency versions only (labstack/echo v4.16.2, go-jose v4.1.6, golang.org/x/net v0.62.0 and the x/ modules those require) to pick up published security fixes. No Hanko source file is changed (tree-delta.txt). Build: go1.26.10, CGO_ENABLED=0 GOOS=linux GOARCH=amd64, `go generate ./build_info` then `go build -a -trimpath -buildvcs=false -o hanko main.go` in backend/ (BUILD-INFO has the exact record and the binary's sha256). The Hanko frontend packages (hanko-elements, frontend-sdk; MIT) are not part of this image. 72dcbfb2b1eaf172e5832219e70811f1c28bc0998a27844e8e87149010583ca3 /usr/local/bin/hankoSecurity notes
- Advisories. The image runs Hanko 3.1.2, which includes the 3.1 fixes for a rate-limit bypass through forwarded headers, TOTP code replay, a third-party redirect allow-list bypass and cross-tenant data leaks. Built with Go 1.26.10 and the dependency updates above,
govulncheckreports no reachable vulnerability. Two echo advisories without a fix on the v4 line do not apply here. GHSA-99jh-6h7p-pp36 concerns echo's reverse-proxy middleware, which Hanko does not use. GHSA-246p-cpwv-v3jq concerns applications that leave echo's client-IP extractor unset, while Hanko always sets one (here:x_real_ip, trusted from nginx only). - Sign-up is open by design. Anyone who can reach the URL can start a sign-up, and it completes only with the e-mail passcode (Step 9). If your app is internal, restrict the security group's 80 and 443 rules to the networks that should use it. Turn self-service sign-up off with
account.allow_signup: falsein the template. - AWS KMS key management (
secrets.key_management.type: aws_kms) needs the instance role credentials from the metadata service, which this image blocks for every service. - The Hanko frontend packages are not part of the image. Load them from your own build or a CDN you trust.
Support
SecureAnyCloud provides 24/7 support for this image by e-mail at sales@secureanycloud.com. Hanko itself is documented at https://docs.hanko.io/.
Deploy on AWS
Launch Hanko 3.1.2 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com