Hanko 3.1.2 on AWS User Guide
Overview
This guide covers launching and using the Hanko 3.1.2 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg identity, secrets and PKI listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
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 you configure. Hanko returns a session JWT that your backend verifies against Hanko's public keys (/.well-known/jwks.json). You put Hanko's ready-made web components (<hanko-auth>, <hanko-profile>) or your own UI on top of its Flow API, and manage users, webhooks and audit logs through its admin API. The image is the Hanko backend only: it has no web console of its own.
The cloudimg image is a hardened, ready-to-use Hanko server:
- A cloudimg build of the upstream 3.1.2 release. Built from the upstream
backend/v3.1.2tag 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/cloudimg-hanko/. - HTTPS from the first boot. nginx is the only public listener (ports 80 and 443) and serves a certificate generated on your instance (an end-entity certificate, not a certificate authority); port 80 redirects to HTTPS. The public address is set from the instance's EC2 public DNS name at first boot and can be changed 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, an nftables rule lets only nginx and root connect to its port, and 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. First boot creates the PostgreSQL cluster, mints this instance's Hanko secret key (it encrypts the JWT signing keys Hanko generates in your empty database) and the admin API password, before anything is reachable.
- 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 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.
What is on the image: Hanko 3.1.2 (backend), nginx, PostgreSQL 16, nftables, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large
Prerequisites
- 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 you add).
- A security group that allows TCP 22 (SSH, also used for the admin API tunnel) from your own networks only, and TCP 443 (the authentication API) and TCP 80 (redirects to HTTPS) from your users. Never open 8000, 8001, 8444 or 5432: 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 it, and an SMTP server for e-mail passcodes.
Step 1: 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 and your Region, then Launch through EC2. Choose m5.large, your key pair, and a security group as above.
With the AWS CLI (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. If you want your own name from the start, put it in /etc/cloudimg/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 none.
Step 2: Connect to your instance
| Operating system | SSH user |
|---|---|
| Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
systemctl is-active hanko-firstboot postgresql@16-main hanko-guard hanko hanko-admin nginx
active
active
active
active
active
active
Only 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, and PostgreSQL has no TCP listener at all:
sudo ss -tln | awk '{print $4}' | grep -E ':(22|80|443|5432|8000|8001|8002|8444)$' | sort -u
0.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
[::]:80
curl -s http://127.0.0.1/health/ready; echo
{"ready":true}
Step 4: Get your addresses and the admin password
sudo cat /root/hanko-credentials.txt
The file (root only, mode 0600) gives the public address, the JWKS URL, the SHA-256 fingerprint of the instance's certificate and the admin API password (shown here 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/cloudimg/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://www.cloudimg.co.uk/guides/hanko-3-1-2-on-ubuntu-24-04-aws/
The non-secret part can be shown without root's file permissions being an issue in shared terminals:
sudo grep -E '^HANKO_(URL|JWKS_URL|TLS_SHA256|ADMIN_API_VIA_SSH_TUNNEL|ADMIN_USER)=' /root/hanko-credentials.txt
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
Step 5: Run the self-test
hanko-selftest exercises the whole path over HTTPS on your instance: it creates a throwaway user through the admin API, signs it in through the public Flow API (e-mail, then password), verifies the RS256 signature of the session JWT Hanko issues against this instance's JWKS, checks that a wrong password is refused, and deletes the user again.
sudo hanko-selftest
sign-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_OK
Step 6: The keys your backend verifies sessions with
Hanko signs every session JWT with a 4096-bit RSA key it generated on your instance's first boot. Your backend fetches the public half from the JWKS endpoint (cache it; the kid in a JWT's header picks the key):
D=$(cat /etc/cloudimg/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, or replace it with a CA-issued certificate (Step 10):
D=$(cat /etc/cloudimg/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.txt
sha256 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:F9
Step 7: Manage 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-9ebd4494c6f6
To give a user a password (for example during a migration), write the JSON to a root-only file so the password does not end up in your shell history or in 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.json
From your workstation, open an SSH tunnel and use the admin API with HTTPS basic authentication (user admin, 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/users
(hanko.crt is a copy of /etc/ssl/hanko/hanko.crt from the instance.) The admin API refuses requests without the password, with a wrong one or with defaults:
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 || true
no password: 401
admin:admin: 401
straight to the admin API port as ubuntu: 000
The full admin API (users, e-mails, passwords, passkeys, sessions, organizations and roles, webhooks, audit logs) is documented at https://docs.hanko.io/api-reference/admin.
Step 8: Connect your application
-
Add your web app's origin to the
cors.allow_originslist in/usr/local/lib/cloudimg/hanko/config.yaml.tmpland re-render the configuration (this also restarts Hanko):bash sudo nano /usr/local/lib/cloudimg/hanko/config.yaml.tmpl # add e.g. - "https://your-app.example.com" under cors.allow_origins sudo hanko-set-domain "$(cat /etc/cloudimg/hanko-domain)" -
Embed Hanko's web components (the MIT-licensed
@teamhanko/hanko-elementspackage) 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
hankocookie (or theAuthorization: Bearerheader you forward) and verify the JWT with the keys fromhttps://<your-hanko-name>/.well-known/jwks.json: algorithm RS256, issuerhttps://<your-hanko-name>, audience the same name.GET /sessions/validateon 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.
Step 9: E-mail passcodes (SMTP or a webhook)
Sign-up by e-mail needs the passcode Hanko sends to the address. The image ships without an SMTP server, so until you configure one, a sign-up stops at the passcode step and no passcode is sent anywhere (it is never written to a log). Sign-in by password, and sign-in of users you create through the admin API, work at once.
Either configure SMTP in /usr/local/lib/cloudimg/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 and Hanko POSTs every message (with the passcode) to it 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"]}'
Step 10: Use your own domain and certificate
Point a DNS record at the instance, then:
sudo hanko-set-domain auth.example.com
This re-renders nginx and Hanko for the new name (WebAuthn relying party, browser origin, 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 and run sudo systemctl reload nginx.
Step 11: 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 cloudimg_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/ || true
tcp 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: 000
Every 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-firstboot
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=0
IPAddressDeny=169.254.169.254/32 ::ffff:169.254.169.254/128 168.63.129.16/32 fd00:ec2::254/128
LimitCORE=0
PostgreSQL has no role with a password and no TCP listener, and only the hanko service account may use the hanko database (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"' || true
0
(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/cloudimg/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" || true
alert protocol version
port 80: 301
unknown Host on 443: 000
Step 12: Passwords 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 and never change it on a running installation: Hanko could 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/cloudimg/hanko-admin.htpasswd. To change it, 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.
Step 13: Your data and backups
Everything Hanko knows (users, credentials, sessions, webhooks, audit logs and its encrypted signing keys) is in the PostgreSQL database hanko. 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.dump
Step 14: Licences and source
Hanko's backend is licensed under the GNU Affero General Public License v3. Users of your Hanko server over a network are entitled to its corresponding source, which is the upstream tag plus the two dependency files cloudimg changed:
head -14 /usr/share/doc/cloudimg-hanko/SOURCE.txt
sha256sum /usr/local/bin/hanko
Hanko 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/hanko
Step 15: Security 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, and 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; it completes only with the e-mail passcode (Step 9). Restrict the security group's 80/443 rules to the networks that should use your app if it is internal. 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
cloudimg provides support for this image by e-mail at support@cloudimg.co.uk. Hanko itself is documented at https://docs.hanko.io/.