Hanko backend provides an HTTP API to build a modern login and registration experience for your users. Its core features are an API for passkeys (WebAuthn), passwords, and passcodes, as well as JWT management.
Hanko backend can be used on its own or in combination with hanko-elements, a powerful frontend library that contains polished and customizable UI flows for password-based and passwordless user authentication that can be easily integrated into any web app with as little as two lines of code.
- API features
- Running the backend
- Running tests
- Additional topics
- API specification
- Configuration reference
- License
- Passkeys (WebAuthn)
- Passcodes
- Passwords
- Email verification
- 2FA (TOTP, security keys)
- JWT management
- Sessions
- User management
- OAuth/OIDC SSO identity providers
- SAML
- Webhooks
Note If you just want to jump right into the experience of passkeys and passcodes, head over to the quickstart guide.
To get the Hanko backend up and running you need to:
- Run a database
- Configure database access
- Apply database migrations
- Run and configure an SMTP server
- Configure JSON Web Key Set generation
- Configure WebAuthn
- Configure CORS
- Start the backend
The following databases are currently supported:
- PostgreSQL
- MySQL
Use Docker to run a container based on the official Postgres image:
docker run --name=postgres \
-e POSTGRES_USER=<DB_USER> \
-e POSTGRES_PASSWORD=<DB_PASSWORD> \
-e POSTGRES_DB=<DB_DATABASE> \
-p <DB_PORT>:5432 \
-d postgresor use the official binary packages to install and run a Postgres instance.
Use Docker to run a container based on the official MySQL image:
docker run --name=mysql \
-e MYSQL_USER=<DB_USER> \
-e MYSQL_PASSWORD=<DB_PASSWORD> \
-e MYSQL_DATABASE=<DB_DATABASE> \
-e MYSQL_RANDOM_ROOT_PASSWORD=true \
-p <DB_PORT>:3306 \
-d mysql:latestor follow the official installation instructions to install and run a MySQL instance.
Open the config.yaml file in the backend/config or create your own *.yaml file and add the following:
database:
user: <DB_USER>
password: <DB_PASSWORD>
host: localhost # change this if the DB is not running on localhost, esp. in a production setting
port: <DB_PORT>
database: <DB_DATABASE>
dialect: <DB_DIALECT> # depending on your choice of DB: postgres, mysqlReplace <DB_USER>, <DB_PASSWORD>, <DB_PORT>, <DB_DATABASE> with the values used in your running
DB instance (cf. the Docker commands above used for running the DB containers) and replace <DB_DIALECT> with
the DB of your choice.
Before you can start and use the service you need to run the database migrations:
docker run --mount type=bind,source=<PATH-TO-CONFIG-FILE>,target=/config/config.yaml -p 8000:8000 -it ghcr.io/teamhanko/hanko:latest migrate upNote The
<PATH-TO-CONFIG-FILE>must be an absolute path to your config file created above.
First build the Hanko backend. The only prerequisite is to have Go (v1.18+) installed on your computer.
go generate ./...
go build -a -o hanko main.goThis command will create an executable with the name hanko, which then can be used to apply the database migrations
and start the Hanko backend.
To apply the migrations, run:
./hanko migrate up --config <PATH-TO-CONFIG-FILE>Note The path to the config file can be relative or absolute.
The Hanko backend requires an SMTP server to send out mails containing passcodes (e.g. for the purpose of email verification, password recovery).
For local development purposes you can use, e.g., Mailslurper. Follow the official installation instructions or use an (inofficial) Docker image to get it up and running:
docker run --name=mailslurper -it -p 2500:2500 -p 8080:8080 -p 8085:8085 @marcopas/docker-mailslurperwhere in this case
2500is the SMTP port of the service8080is the port for the GUI application for managing mails8085is the port for the API service for managing mails
When using the above Docker command to run a Mailslurper container, it does not configure
a user/password, so a minimal configuration in your configuration file (backend/config/config.yaml or
your own *.yaml file) could contain the following:
email_delivery:
enabled: true
email:
from_address: no-reply@example.com
from_name: Example Application
smtp:
host: localhost
port: 2500To ensure that passcode emails also contain a proper subject header, configure a service name:
service:
name: Example Authentication ServiceIn a production setting you would rather use a self-hosted SMTP server or a managed service like AWS SES. In that case
you need to supply the email_delivery.smtp.host, email_delivery.smtp.port as well as the email_delivery.smtp.user,
email_delivery.smtp.password settings according to your server/service settings.
The API uses JSON Web Tokens (JWTs) for
authentication.
JWTs are verified using JSON Web Keys (JWK).
JWKs are created internally by setting secrets.keys options in the
configuration file (backend/config/config.yaml or your own *.yaml file):
secrets:
keys:
- <CHANGE-ME>Note at least one
secrets.keysentry must be provided and each entry must be a random generated string at least 16 characters long.
Keys secrets are used to en- and decrypt the JWKs which get used to sign the JWTs. For every key a JWK is generated, encrypted with the key and persisted in the database.
The Hanko backend API publishes public cryptographic keys as a JWK set through the .well-known/jwks.json
endpoint to enable clients to verify token
signatures.
Passkeys are based on the Web Authentication API. In order to create and login with passkeys, the Hanko backend must be provided information about the WebAuthn Relying Party.
For most use cases, you just need the domain of your web application that uses the Hanko backend. Set
webauthn.relying_party.id to the domain and set webauthn.relying_party.origin to the domain including the
protocol.
Important: If you are hosting your web application on a non-standard HTTP port (i.e.
80) you also have to include this in the origin setting.
When developing locally, the Hanko backend defaults to:
webauthn:
relying_party:
id: "localhost"
display_name: "Hanko Authentication Service"
origins:
- "http://localhost"so no further configuration changes need to be made to your configuration file.
When you have a website hosted at example.com and you want to add a login to it that will be available
at https://example.com/login, the WebAuthn config would look like this:
webauthn:
relying_party:
id: "example.com"
display_name: "Example Project"
origins:
- "https://example.com"If the login should be available at https://login.example.com instead, then the WebAuthn config would look like this:
webauthn:
relying_party:
id: "login.example.com"
display_name: "Example Project"
origins:
- "https://login.example.com"Given the above scenario, you still may want to bind your users WebAuthn credentials to example.com if you plan to
add other services on other subdomains later that should be able to use existing credentials. Another reason can be if
you want to have the option to move your login from https://login.example.com to https://example.com/login at some
point. Then the WebAuthn config would look like this:
webauthn:
relying_party:
id: "example.com"
display_name: "Example Project"
origins:
- "https://login.example.com"Because the backend and your application(s) consuming backend API most likely have different origins, i.e. scheme (protocol), hostname (domain), and port part of the URL are different, you need to configure Cross-Origin Resource Sharing (CORS) and specify your application(s) as allowed origins:
server:
public:
cors:
allow_origins:
- https://example.comWhen you include a wildcard * origin you need to set unsafe_wildcard_origin_allowed: true:
server:
public:
cors:
allow_origins:
- "*"
unsafe_wildcard_origin_allowed: trueWildcard * origins can lead to cross-site attacks and when you include a * wildcard origin,
we want to make sure, that you understand what you are doing, hence this flag.
Note In most cases, the
allow_originslist here should contain the same entries as thewebauthn.relying_party.originslist. Only when you have an Android app you will have an extra entry (android:apk-key-hash:...) in thewebauthn.relying_party.originslist.
The Hanko backend consists of a public and an administrative API (currently providing user management endpoints). These can be started separately or in a single command.
docker run --mount type=bind,source=<PATH-TO-CONFIG-FILE>,target=/config/config.yaml -p 8000:8000 -it ghcr.io/teamhanko/hanko:latest serve publicEach GitHub release (> 0.9.0) has hanko's binary assets uploaded to it. Alternatively you can use
a tool like eget to install binaries from releases on GitHub:
eget teamhanko/hankogo generate ./...
go build -a -o hanko main.goThen run:
./hanko serve public --config <PATH-TO-CONFIG-FILE>Note The
<PATH-TO-CONFIG-FILE>must be an absolute path to your config file created above.
8000 is the default port for the public API. It can
be customized in the
configuration through the server.public.address option.
The service is now available at localhost:8000.
In the usage section above we only started the public API. Use the command below to start the admin API. The default
port is 8001, but can be
customized in the configuration
through the server.admin.address option.
serve adminWarning The admin API must be protected by an access management system.
Use this command to start the public and admin API together:
serve allYou can run the unit tests by running the following command within the backend directory:
go test -v ./...Password-based authentication is disabled per default. You can activate it and set the minimum password length in your configuration file:
password:
enabled: true
min_password_length: 8JWTs used for authentication are propagated via cookie. If your application and the Hanko backend run on different
domains, cookies cannot be set by the Hanko backend. In that case the backend must be configured to transmit the JWT via
Header (X-Auth-Token). To do so, enable propagation of the X-Auth-Token header:
session:
enable_auth_token_header: trueAPI operations are recorded in an audit log. By default, the audit log is enabled and logs to STDOUT:
audit_log:
console_output:
enabled: true
output: "stdout"
storage:
enabled: falseTo persist audit logs in the database, set audit_log.storage.enabled to true.
Hanko implements basic fixed-window rate limiting for the passcode/init and password/login endpoints to mitigate brute-force attacks. It uses a combination of user-id/IP to mitigate DoS attacks on user accounts. You can choose between an in-memory and a redis store.
In production systems, you may want to hide the Hanko service behind a proxy or gateway (e.g. Kong, Traefik) to provide additional network-based rate limiting.
Hanko supports OAuth-based (authorization code flow) third
party provider logins. The third_party configuration
option contains all relevant configuration.
This includes options for setting up redirect URLs (in case of success or error on authentication with a provider) that
apply to both built-in and
custom providers.
Built-in providers can be configured through the third_party.providers configuration option.
They must be explicitly enabled (i.e. providers are disabled default).
All provider configurations require provider credentials in the form of a client ID (client_id)
and a client secret (secret). See the guides in the official documentation for instructions on how to obtain these:
Custom providers can be configured through the third_party.custom_providers configuration
option.
Like built-in providers they must be explicitly enabled and require a client_id and secret, which must
be obtained from the respective provider.
Custom providers can use either OAuth or OIDC. OIDC providers can be configured to use
OIDC Discovery by setting the use_discovery
option to true. An issuer must be configured too in that case. Otherwise both OAuth and OIDC providers
can manually define required endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint).
scopes must be explicitly defined (with openid being the minimum requirement in case of OIDC providers).
The allow_linking configuration option for built-in and custom providers determines whether automatic account linking for this provider
is activated. Note that account linking is based on e-mail addresses and OAuth providers may allow account holders to
use unverified e-mail addresses or may not provide any information at all about the verification status of e-mail
addresses. This poses a security risk and potentially allows bad actors to hijack existing Hanko
accounts associated with the same address. It is therefore recommended to make sure you trust the provider and to
also enable emails.require_verification in your configuration to ensure that only verified third party provider
addresses may be used.
Hanko allows for defining arbitrary user metadata. Metadata can be categorized into three types that differ as to how they can be accessed and modified:
| Metadata type | Public API | Admin API |
|---|---|---|
| Private | No read or write access | Read and write access |
| Public | Read access | Read and write access |
| Unsafe | Read access and write access | Read and write access |
Each metadata type supports a maximum of 3,000 characters. Metadata is stored as compact JSON (whitespace is ignored).
JSON syntax characters ({, :, ", }) count toward the character limit.
Multibyte UTF-8 characters (like emojis or non-Latin characters) count as 1 character each.
Private metadata should be used for sensitive data that should not be exposed to the client (e.g., internal flags/ids, configuration, or access control details).
Private metadata can be read through the Admin API only using the Get metadata of a user endpoint.
Private metadata can be set and modified through the Admin API only by using the Patch metadata of a user endpoint.
Public metadata should be used for non-sensitive information that you want accessible but not modifiable by the client (e.g., certain user roles, UI preferences, display options).
Public metadata can be read through the Public API, the Admin API and in JWT templates for customizing the session JWT:
Public API:- Public metadata is returned in the
userobject in the payload on thesuccessstate in a Login and Registration flow as well as in the payload on theprofile_initstate in a Profile flow. - Public metadata is returned as part of the response of the Get a user by ID endpoint.
- Public metadata is returned in the
Admin API:- Public metadata is returned as part of the response of the Get metadata of a user endpoint.
- Public metadata is returned as part of the response of the Get a user by ID endpoint.
JWT Templates:- Public metadata can be accessed through the
Usercontext object available on session JWT customization. See Session JWT templates for more details.
- Public metadata can be accessed through the
Public metadata can be set and modified through the Admin API only by using the Patch metadata of a user endpoint.
Unsafe metadata should be used for non-sensitive, temporary or experimental data that doesn't need strong safety guarantees.
Unsafe metadata can be read through the Public API, the Admin API and in JWT templates for customizing the session JWT:
Public API:- Unsafe metadata is returned in the
userobject in the payload on thesuccessstate in a Login and Registration flow as well as in the payload on theprofile_initstate in a Profile flow. - Unsafe metadata is returned as part of the response of the Get a user by ID endpoint.
- Unsafe metadata is returned in the
Admin API:- Unsafe metadata is returned as part of the response of the Get metadata of a user endpoint.
- Unsafe metadata is returned as part of the response of the Get a user by ID endpoint.
JWT Templates:- Unsafe metadata can be accessed through the
Usercontext object available on session JWT customization. See Session JWT templates for more details.
- Unsafe metadata can be accessed through the
Unsafe metadata can be set and modified through the Public API and the Admin API:
-
Public API:- Unsafe metadata can be set using the
patch_metadataaction in the Profile flow.
- Unsafe metadata can be set using the
-
Admin API:- Unsafe metadata can be set using the Patch metadata of a user endpoint.
You can import an existing user pool into Hanko using json in the following format:
[
{
"user_id": "799e95f0-4cc7-4bd7-9f01-5fdc4fa26ea3",
"emails": [
{
"address": "koreyrath@wolff.name",
"is_primary": true,
"is_verified": true
}
],
"created_at": "2023-06-07T13:42:49.369489Z",
"updated_at": "2023-06-07T13:42:49.369489Z"
},
{
"user_id": "",
"emails": [
{
"address": "joshuagrimes@langworth.name",
"is_primary": true,
"is_verified": true
}
],
"created_at": "2023-06-07T13:42:49.369494Z",
"updated_at": "2023-06-07T13:42:49.369494Z"
}
]There is a json schema file located here that you can use for validation and input suggestions. To import users run:
hanko user import -i ./path/to/import_file.json
Webhooks are an easy way to get informed about changes in your Hanko instance (e.g. user or email updates). To use webhooks you have to provide an endpoint on your application which can process the events. Please be aware that your endpoint need to respond with an HTTP status code 200. Else-wise the delivery of the event will not be counted as successful.
When a webhook is triggered it will send you a JSON body which contains the event and a jwt. The JWT contains 2 custom claims:
- data: contains the whole object for which the change was made. (e.g.: the whole user object when an email or user is changed/created/deleted)
- evt: the event for which the webhook was triggered
A typical webhook event looks like:
{
"token": "the-jwt-token-which-contains-the-data",
"event": "name of the event"
}To decode the webhook you can use the JWKs created in Configure JSON Web Key Set generation
Hanko sends webhooks for the following event types:
| Event | Triggers on |
|---|---|
| user | user creation, user deletion, user update, email creation, email deletion, change of primary email |
| user.create | user creation |
| user.delete | user deletion |
| user.login | user login |
| user.update | user update, email creation, email deletion, change of primary email |
| user.update.email | email creation, email deletion, change of primary email |
| user.update.email.create | email creation |
| user.update.email.delete | email deletion |
| user.update.email.primary | change of primary email |
| user.update.username.create | username creation |
| user.update.username.delete | username deletion |
| user.update.username.update | change of username |
| email.send | an email was sent or should be sent |
As you can see, events can have subevents. You are able to filter which events you want to receive by either selecting a parent event when you want to receive all subevents or selecting specific subevents.
You can activate webhooks by adding the following snippet to your configuration file:
webhooks:
enabled: true
hooks:
- callback: <YOUR WEBHOOK ENDPOINT>
events:
- userWebhooks include comprehensive SSRF (Server-Side Request Forgery) protection to prevent attacks on internal networks and metadata endpoints. The security system validates callback URLs both at configuration time and during webhook delivery.
The webhook security mode determines which destination IPs are allowed:
public_only (default)
Only allows callbacks to public, routable IP addresses. Blocks:
- Private networks (10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12)
- Loopback addresses (127.0.0.0/8)
- Link-local addresses (169.254.0.0/16)
- Cloud metadata endpoints (169.254.169.254, fe80::/10, fc00::/7)
- Reserved IP ranges
webhooks:
enabled: true
security:
mode: public_only
allowed_schemes:
- https
hooks:
- callback: https://api.example.com/webhooks
events:
- userNote: In
public_onlymode, onlyallowed_schemesis effective. Anyallowed_*orblocked_*configuration options (exceptallowed_schemes) will be ignored if configured, and a warning will be logged at startup.
internal_only
Only allows callbacks to internal/private IP addresses. Blocks all public IPs. Useful for deployments where webhooks should only target internal services:
webhooks:
enabled: true
security:
mode: internal_only
allowed_schemes:
- http
- https
hooks:
- callback: http://10.0.1.50/webhooks
events:
- userNote: In
internal_onlymode, onlyallowed_schemesis effective. Anyallowed_*orblocked_*configuration options (exceptallowed_schemes) will be ignored if configured, and a warning will be logged at startup.
custom (requires explicit configuration)
Provides fine-grained control over webhook destinations. At least one allowlist must be configured (allowed_hosts, allowed_domains, or allowed_cidrs) - this follows the principle of least privilege with no implicit defaults.
You can define security rules using:
- Allowlists: Explicitly permit specific hosts, domains, or IP ranges
- Blocklists (optional): Further restrict the allowed set by blocking specific destinations
Important:
custommode requires at least one allowlist to be configured. Configuration will fail without one.- For each category (hosts, domains, CIDRs), you must choose either allowlist OR blocklist, not both.
- If you want to allow all destinations, use
insecuremode instead (not recommended for production).
Example: Allow specific hosts/domains
webhooks:
enabled: true
security:
mode: custom
allowed_schemes:
- https
# Allow specific internal hosts (supports hostnames and IP addresses)
allowed_hosts:
- internal-webhook-server.local
- 192.168.1.100
# Allow internal domains and all subdomains
allowed_domains:
- internal.company.com
# IMPORTANT: For hostnames to work, you must also allow their resolved IPs
# See "Understanding DNS Resolution and IP Validation" section below
allowed_cidrs:
- 192.168.1.0/24 # IP range where internal hostnames resolve
# Alternative: Use skip_resolved_ip_validation: true to trust DNS
hooks:
- callback: https://internal-webhook-server.local/hook
events:
- user.create
- callback: https://192.168.1.100/webhook
events:
- user.updateNote: When using hostnames in
custommode, both the hostname AND its resolved IPs must be allowed. See the DNS Resolution and IP Validation section for details.
Example: Allow IP ranges with CIDRs
webhooks:
enabled: true
security:
mode: custom
allowed_schemes:
- https
# Allow specific IP ranges (use CIDR notation)
allowed_cidrs:
- 10.0.0.0/24 # Entire subnet
- 192.168.1.50/32 # Single IP
hooks:
- callback: https://10.0.0.15/webhook
events:
- userExample: Allowlist with additional blocklist restrictions
webhooks:
enabled: true
security:
mode: custom
allowed_schemes:
- https
# Allow broad set of domains
allowed_domains:
- example.com
# For domain names to work, also allow their resolved IPs
allowed_cidrs:
- 93.184.216.0/24 # IP range where example.com resolves
# Or use: skip_resolved_ip_validation: true
# But block specific subdomains (blocklist further restricts the allowed set)
blocked_hosts:
- suspicious.example.com
- test.example.com
hooks:
- callback: https://api.example.com/webhooks # ✅ Allowed
events:
- user
# callback: https://suspicious.example.com # ❌ BlockedExample: Mixed approach (different categories)
You can use allowlist for one category and blocklist for another:
webhooks:
enabled: true
security:
mode: custom
allowed_schemes:
- https
# Allowlist hostnames (only these hosts allowed)
allowed_hosts:
- api.example.com
- 93.184.216.34 # IP where api.example.com resolves
# Blocklist specific domains (further restrict by blocking subdomains)
blocked_domains:
- blocked.example.com
hooks:
- callback: https://api.example.com/webhooks # ✅ Allowed
events:
- user
# callback: https://blocked.example.com/hook # ❌ BlockedNote on
allowed_hosts: This field accepts both hostnames and IP addresses. For example:allowed_hosts: - webhook.example.com # hostname - 192.168.1.100 # IP addressAlternatively, you can use
allowed_cidrsfor IP ranges in CIDR notation (e.g.,192.168.1.100/32for a single IP).
insecure (development only)
Allows any destination. Not recommended for production.
webhooks:
enabled: true
security:
mode: insecure
allowed_schemes:
- http
- httpsImportant: Two-Phase Validation
When using hostnames in custom mode, webhook validation occurs in two phases:
- Hostname Validation: Checks if the hostname is in
allowed_hostsorallowed_domains - IP Validation: After DNS resolution, checks if all resolved IPs are in
allowed_cidrs(or are literal IPs inallowed_hosts)
Both phases must pass. This defense-in-depth approach protects against:
- DNS hijacking/poisoning attacks
- DNS rebinding attacks (mitigated through IP pinning)
- Compromised DNS servers
Example - What Works:
webhooks:
security:
mode: custom
allowed_hosts:
- webhook.example.com
allowed_cidrs:
- 93.184.216.0/24 # IP range where webhook.example.com resolvesExample - What Doesn't Work:
webhooks:
security:
mode: custom
allowed_hosts:
- webhook.example.com # ❌ Hostname alone is not enough
# Missing: allowed_cidrs for the resolved IPsError you'll see:
resolved IP '93.184.216.34' for host 'webhook.example.com' is not allowed:
IP '93.184.216.34' is not in the allowed CIDR or host list
Configuration Strategies:
-
For hostnames with known IP ranges:
allowed_hosts: [webhook.example.com] allowed_cidrs: [93.184.216.0/24]
-
For hostnames with stable IPs:
allowed_hosts: [webhook.example.com, 93.184.216.34] # Add resolved IP
-
For any public hostnames (simpler but less restrictive):
mode: public_only # Allows any hostname that resolves to public IPs
-
For internal hostnames (trusted network):
mode: internal_only # Allows hostnames resolving to private IPs
Trust DNS (Alternative Approach):
If you fully trust your DNS infrastructure, you can skip IP validation for allowed hostnames:
webhooks:
security:
mode: custom
allowed_hosts:
- webhook.example.com
skip_resolved_ip_validation: true # Trust DNS - resolved IPs auto-allowed
⚠️ Security Warning: Only enableskip_resolved_ip_validationif you fully trust your DNS infrastructure. If DNS is compromised, an attacker could makewebhook.example.comresolve to internal services like127.0.0.1. The default (false) provides defense-in-depth by requiring both hostname AND IP validation.
Why This Design?
This two-phase approach provides defense-in-depth security:
- Even if DNS is compromised, an attacker cannot make
webhook.example.comresolve to an arbitrary IP (unlessskip_resolved_ip_validationis enabled) - The resolved IP must also be in your allowed CIDR ranges
- Combined with IP pinning (automatic), this prevents DNS rebinding attacks
Note: The system automatically pins validated IPs during webhook delivery, so even if DNS changes between validation and delivery, the connection goes to the validated IP.
Control how webhooks handle HTTP redirects:
webhooks:
security:
mode: public_only
follow_redirects: true
max_redirects: 3Note: Each redirect target is validated against the security policy. Set
follow_redirects: false(default) to reject all redirects.
- Choose the appropriate mode:
- Use
public_only(default) for external webhooks to SaaS services - Use
internal_onlywhen webhooks should only target internal services - Use
customwhen you need fine-grained control with explicit allowlists - Never use
insecuremode in production
- Use
- Custom mode follows allowlist-first (principle of least privilege):
- At least one allowlist (
allowed_hosts,allowed_domains, orallowed_cidrs) is required - Start with a narrow allowlist and expand as needed
- Use blocklists to further restrict if necessary
- At least one allowlist (
- Keep it simple: For each category (hosts, domains, CIDRs), use either allowlist OR blocklist, not both
- Use HTTPS only by setting
allowed_schemes: ["https"] allowed_hostsaccepts both hostnames and IPs - use whichever is most appropriate for your use case- Understand hostname vs IP validation in custom mode:
- Hostnames must ALSO have their resolved IPs allowed via
allowed_cidrs(default behavior) - Or use
skip_resolved_ip_validation: trueif you fully trust your DNS - For simpler configuration with hostnames, consider
public_only/internal_onlymodes
- Hostnames must ALSO have their resolved IPs allowed via
- Disable redirects unless required:
follow_redirects: false - Regularly review webhook destinations and events
- Monitor webhook failures for potential attack attempts
- Enable error sanitization in production to prevent information disclosure:
sanitize_errors: true
The webhook system automatically blocks cloud provider metadata endpoints:
- AWS: 169.254.169.254
- GCP: metadata.google.internal
- IPv6 metadata ranges
- Common DNS rebinding bypass attempts
This protection is always active when deny_metadata_endpoints: true (default).
To prevent information disclosure through error messages, enable error sanitization:
webhooks:
security:
mode: public_only
sanitize_errors: trueWhen sanitize_errors is enabled:
- Returned errors are generic and don't reveal internal network details
- Instead of:
"resolved IP '10.0.0.5' for host 'internal.local' is not allowed" - Returns:
"callback destination not allowed"
- Instead of:
- Detailed errors are still logged internally for debugging
- Recommended for production to prevent information leakage during attacks
Example sanitized error messages:
"callback URL validation failed"- Generic validation failure"callback URL not allowed"- Host/domain blocked"callback destination not allowed"- IP blocked or invalid"redirect destination not allowed"- Redirect target blocked
Security vs. Debugging Trade-off:
- Development: Set
sanitize_errors: falsefor detailed debugging - Production: Set
sanitize_errors: trueto prevent information disclosure - Detailed errors are always available in server logs regardless of this setting
For complete configuration options, see the webhook configuration reference.
You can define custom claims that will be added to session JWTs through the session.jwt_template.claims
configuration option.
These claims are processed at JWT generation time and can include static values, templated strings using Go's text/template syntax, or nested structures (maps and slices).
The template has access to user data via the .User field, which includes:
.User.UserID: The user's unique ID (string).User.Email: Email details (optional)User.Email.Address: The actual email addressUser.Email.IsPrimary: Whether this email address is the primary email address of this userUser.Email.IsVerified: Whether this email address has been verified by the user
.User.FamilyName: The user's family name (string, optional).User.GivenName: The user's given name (string, optional).User.Name: The user's full name (string, optional).User.Picture: The user's profile picture URL (string, optional).User.Username: The user's username (string, optional).User.Metadata: The user's public and unsafe metadata (optional).User.Metadata.Public: The user's public metadata (object).User.Metadata.Unsafe: The user's unsafe metadata (object)
.User.Metadata.Public and .User.Metadata.Unsafe can be accessed and queried using
GJSON Path Syntax (try it out in the
playground).
Assume that a user's public metadata consisted of the following data:
{
"display_name": "GamerDude",
"favorite_games": [
{
"name": "Legends of Valor",
"genre": "RPG",
"playtime_hours": 142.3
},
{
"name": "Space Raiders",
"genre": "Sci-Fi Shooter",
"playtime_hours": 87.6
}
]
}Then you could, for example, access this data in the following ways in your templates:
display_name: '{{ .User.Metadata.Public "display_name" }}'
favorite_games: '{{ .User.Metadata.Public "favorite_games" }}'
favorite_games_with_playtime_over_100: '{{ .User.Metadata.Public "favorite_games.#(playtime_hours>100)" }}'
favorite_genres: '{{ .User.Metadata.Public "favorite_games.#.genre" }}'Note
Ensure you use proper quoting when accessing metadata.
.User.Metadata.Publicand.User.Metadata.Unsafeare function calls internally and the given path argument must be a string, so it must be double quoted. If you use use double quotes for your entire claim template then the path argument must be escaped, i.e.:"{{ .User.Metadata.Public \"display_name\" }}"
Example usage in YAML configuration:
role: "user" # Static value
user_email: "{{.User.Email.Address}}" # Templated string
is_verified: "{{.User.Email.IsVerified}}" # Boolean from user data
metadata: # Nested map
greeting: "Hello {{.User.Username}}"
source: '{{ .User.Metadata.Public "display_name" }}' # Data read from public metadata
ui_theme: '{{ .User.Metadata.Unsafe "ui_theme" }}' # Data read from unsafe metadata
scopes: # Slice with templated value
- "read"
- "write"
- "{{if .User.Email.IsVerified}}admin{{else}}basic{{end}}"In this example:
roleis a static string ("user").user_emaildynamically inserts the user's email address.is_verifiedinserts a boolean indicating email verification status.metadatais a nested map with a staticsourceand a templatedgreeting.scopesis a slice combining static values and a conditional template.
Notes:
- Custom claims are added at the top level of the session token payload.
- Claims with the following keys will be ignored because they are currently added to the JWT by default:
subiatexpaudissemailusernamesession_id
- Templates must conform to valid Go text/template syntax. Invalid templates are logged and excluded from the generated token.
- Boolean strings ("true" or "false") from templates are automatically converted to actual booleans.
For more details on template syntax, see: https://pkg.go.dev/text/template
The Hanko backend ist licensed under the AGPL-3.0.