Auth Policy Server Integration
yarilo-auth can POST every login attempt to an external HTTP policy service that decides whether to allow, reject, or tarpit the request. The wire shape follows the wforce (weakforced) HTTP API so any policy server speaking that dialect plugs in without a custom adapter.
When to enable
A policy server is useful when:
- credential-stuffing detection needs cross-IP pattern analysis (one IP brute-forcing 1000 distinct users - invisible to per-IP penalty)
- access policies depend on signals that don't fit a SQL passdb: geolocation, time of day, fraud-score, SIEM integration
- multi-tenant deployments need per-tenant abuse policies
- compliance requires policy decisions in an audit pipeline
When the deployment is small and the threat model is satisfied by in-process auth_ + auth_ + per-user@IP connection limits, the policy hook is overkill.
Configuration
auth. in yarilo. (the Helm names are below):
auth:
policy:
auth_policy_server_url: "https://wforce.internal:8084/?ctx=imap"
auth_policy_server_api_header: "X-API-Key: change-me"
auth_policy_hash_mech: sha256
auth_policy_hash_nonce: "deployment-specific-salt-please-rotate"
auth_policy_hash_truncate: 12
timeout_ms: 5000
auth_policy_reject_on_fail: false
auth_policy_log_only: false
auth_policy_check_before_auth: true
auth_policy_check_after_auth: true
auth_policy_report_after_auth: trueauth_policy_server_url
- Default
- none
- Type
- string
- Also accepted as
url- Helm
components.auth.policy.url
The policy server. Empty disables the hook. A URL ending in & extends its query string instead of starting a new one (https:/).
auth_policy_server_api_header
- Default
- none
- Type
- string
- Also accepted as
api_header- Helm
components.auth.policy.api_header- See also
api_header_secret_ref
"Key: value" sends a header of your own; "value" sends X-API-Key: value. ${ENV} is expanded.
auth_policy_hash_mech
- Default
sha256- Type
- string
- Values
sha256sha512- Also accepted as
hash_mech- Helm
components.auth.policy.hash_mech
Digest of pwhash. It must match the policy server's setting.
auth_policy_hash_nonce
- Default
- none
- Type
- string
- Also accepted as
hash_nonce- Helm
components.auth.policy.hash_nonce- See also
hash_nonce_secret_ref
Salt mixed into pwhash, required when a policy server is set. Two deployments with different nonces have different hash spaces. ${ENV} is expanded.
auth_policy_hash_truncate
- Default
12- Type
- integer, bits
- Also accepted as
hash_truncate_bits- Helm
components.auth.policy.hash_truncate_bits
Top bits of the digest that are sent. 12 gives 4096 buckets, enough for rate-limit patterns and useless for password recovery. 0 sends the full digest.
timeout_ms
- Default
5000- Type
- integer, milliseconds
- Helm
components.auth.policy.timeout_ms
Cap on one round trip to the policy server.
auth_policy_reject_on_fail
- Default
false- Type
- boolean
- Also accepted as
reject_on_fail- Helm
components.auth.policy.reject_on_fail
true rejects the login when the policy server is unreachable or answers something malformed. false carries on without its guidance.
auth_policy_log_only
- Default
false- Type
- boolean
- Also accepted as
log_only- Helm
components.auth.policy.log_only- Changed 2.4.5
- A tarpit, and a refusal from
auth_policy_reject_on_fail, are no longer applied in log-only mode.
Ask the policy server and log its verdicts without enforcing them, to roll out a new policy in shadow mode: no tarpit, no refusal, and no refusal from auth_ when the server fails.
auth_policy_check_before_auth
- Default
true- Type
- boolean
- Also accepted as
check_before- Helm
components.auth.policy.check_before
Ask ?command=allow before the passdb chain runs. A rejection skips the passdb call entirely.
auth_policy_check_after_auth
- Default
true- Type
- boolean
- Also accepted as
check_after- Helm
components.auth.policy.check_after
Ask ?command=allow once the chain's result is known. A rejection turns a successful login into a failure (account-takeover detection).
auth_policy_report_after_auth
- Default
true- Type
- boolean
- Also accepted as
report_after- Helm
components.auth.policy.report_after
Send ?command=report after every decision, without waiting for the answer. It never delays the reply to the client.
auth_policy_request_attributes
- Default
device_id: %{client_id}, fail_type: %{fail_type}, login: %{requested_username}, protocol: %{protocol}, pwhash: %{hashed_password}, remote: %{remote_ip}, session_id: %{session}- Type
- map
- Helm
components.auth.policy.request_attributes- Added
- 2.4.5
The request body: a body key to its value template. A key name/ puts sub inside the object name. See Request body for the variables. An entry adds or replaces one key; a default key cannot be removed. A template naming an unknown variable or filter, or the env:, process: or system: provider, stops yarilo-auth at start.
Helm
The chart builds auth. from components., with shorter names; each card above names its chart value.
From 2.4.2 a key under its auth. name fails the render and names the chart's, and check_, check_ and report_ set to false and hash_ reach the config. In 2.4.1 such a key is dropped, the three switches cannot be turned off through the chart (a false is rendered as true), and hash_ is rendered as 12.
auth_ and auth_ expand ${ENV}, so both can come from a Secret; 2.4.1 and earlier take them literally. Through the chart, name the Secret in *_ to keep the value out of the config ConfigMap:
components:
auth:
policy:
url: https://wforce.internal:8084/
api_header_secret_ref:
name: yarilo-policy
key: api_header # the default
hash_nonce_secret_ref:
name: yarilo-policy
key: hash_nonce # the defaultThe chart renders "${YARILO_ and "${YARILO_ and sets both variables in the yarilo-auth pod from the Secret. Setting a value and its *_ together fails the render.
api_header_secret_ref
- Set in
- Helm values
- Default
name: , key: api_header- Type
- map
- See also
auth_policy_server_api_header
A Secret (name, key) holding the API header. The chart renders "${YARILO_ and sets the variable in the yarilo-auth pod, so the value stays out of the ConfigMap. Setting it together with api_ fails the render.
hash_nonce_secret_ref
- Set in
- Helm values
- Default
name: , key: hash_nonce- Type
- map
- See also
auth_policy_hash_nonce
A Secret (name, key) holding the nonce. The chart renders "${YARILO_ and sets the variable in the yarilo-auth pod. Setting it together with hash_ fails the render.
Wire shape
Every request is POST {url}?command={allow|report} with header Content-Type: application/ and a JSON body. Every login is checked, whatever the mechanism: PLAIN, LOGIN, SCRAM-SHA-1 and SCRAM-SHA-256 with or without -PLUS, OAUTHBEARER and XOAUTH2.
The default body:
{
"device_id": "\"name\" \"Thunderbird\" \"version\" \"140.4.0\"",
"fail_type": "credentials",
"login": "alice@example.com",
"protocol": "imap",
"pwhash": "ae30",
"remote": "203.0.113.42",
"session_id": "dGVzdHNlc3Npb24",
"tls": true,
"success": false,
"policy_reject": false
}successandpolicy_appear only in the check after the login and inreject reportcalls.- Keys are in alphabetical order, so the payload is byte-stable across releases.
Request body
auth_ sets the body: each key is sent as a string expanded from its template. A key name/ puts sub inside the object name. tls, and success and policy_ where they apply, always follow the attributes.
An entry adds a key or replaces one; a key of the defaults cannot be removed.
Add the client's local address to the defaults:
auth:
policy:
auth_policy_request_attributes:
attrs/local: "%{local_ip}"A key a/ nests as deep as it names: c inside b inside a. A key cannot be both a value and an object.
Templates
A template is text with %{. expressions. An expression names a variable, then filters joined with |:
auth:
policy:
auth_policy_request_attributes:
attrs/domain: "%{user | domain}"
attrs/client: "%{real_remote_ip}:%{real_remote_port}"
attrs/tenant: "%{passdb:tenant | default('none')}"%%{is a literal%{. A%before anything else,%%included, is sent as it is.- Filters take positional and named arguments, as in
%{user | substr(0, 4)}and%{user | hash('sha256', rounds=2)}. An argument can name a variable. - The language has arithmetic (
%{remote_), comparisons withport + 1} ifandswitch,regexp, hashes,base64andhex,encryptanddecrypt, and dates.defaultgives a value when the expression before it has none or fails. regexpand the~comparison use RE2 syntax: no backreferences, no lookaround.encryptanddecrypttake AES-128, AES-192 or AES-256 in CBC or ECB mode.- A value is JSON-escaped before it goes into the body.
safesends it unescaped, so a value markedsafemust already be valid inside a JSON string.
A template naming an unknown variable or filter stops yarilo-auth at start with the key and the reason, rather than sending an empty value.
Variables
| Variable | Value |
|---|---|
%{requested_ | the login name as the client sent it |
%{user}, %{owner_ | the user; after the login, as the passdb resolved it |
%{original_, %{auth_ | the login name, as requested_ |
%{domain_, %{domain_ | the part after the first or the last @ |
%{password} | the password in plain text |
%{hashed_ | the pwhash below |
%{protocol} | imap, pop3, submission, sieve, lmtp, jmap |
%{mechanism} | the SASL mechanism, as PLAIN or SCRAM-SHA-256 |
%{secured} | TLS when the client's connection is TLS, else empty |
%{remote_, %{remote_ | the client's address and port |
%{local_, %{local_ | the address and port the client connected to |
%{real_, %{real_ | the peer's own address and port |
%{real_, %{real_ | the local end of that connection |
%{local_ | the TLS server name (SNI) the client asked for |
%{ssl_ | the JA3 hash of the client's TLS hello |
%{client_ | the client's IMAP ID, see Client fields |
%{session} | the login's session ID |
%{client_ | the process ID of the login process |
%{id} | after the login, the position of the passdb that answered, from 1 |
%{fail_ | why a login failed, see below |
%{cert}, %{ssl_, %{ssl_ | always empty |
%{login_, %{master_, %{session_, %{home} | always empty |
- Behind a PROXY header or a trusted proxy (XCLIENT, or the IMAP
IDof a login ingeneral.),xclient. trusted_ nets remote_is the client the proxy names andip real_is the proxy itself. Without one, theremote_ ip real_variables equal the plain ones. - A port the login did not learn is
0. %{password}sends the password itself to the policy server. Use%{hashed_unless the server must see the password.password} - The login pods do not ask for a client certificate, so the certificate variables are empty.
- Master logins skip the policy server, and the check runs before any session exists, so the other four are empty.
Providers
A variable prefix:name is read from a provider:
| Provider | Value |
|---|---|
%{passdb:name} | a field the passdb returned, empty when it returned none |
%{userdb:name} | a userdb_ field the passdb returned, without the prefix |
%{date:name} | the date: year, month, day |
%{time:name} | the time: hour, min, sec, us, unix |
%{generate:name} | a new guid, guid128 or uuid |
The passdb fields exist only after the login; in the check before it they are empty.
env:, process: and system: read yarilo-auth's own environment and host. They are refused in the request body: a template naming one stops yarilo-auth at start, so a secret of its own never reaches the policy server. event: is not available.
Client fields
tlsis true when the client's connection is TLS, implicit or after STARTTLS. JMAP, served over HTTPS only, is always TLS.session_is the session ID the login process gave the connection.id device_is the IMAPid IDthe client sent before login, kept only withprotocol.. Each pair is quoted, as inimap. imap_ id_ retain "name" "Thunderbird" "version" "140.; the login's own4. 0" x-keys are left out. Without the setting, and for other protocols, it is empty.
fail_type
| Value | Meaning |
|---|---|
| empty | the login succeeded, or the call is the check before it |
credentials | a passdb knew the user and refused the password |
account | no passdb knew the user |
internal | a passdb could not answer |
policy | the policy server refused the login |
expired and disabled are valid values for a policy server but are not sent: no passdb reports them.
pwhash
digest = hash_mech(hash_nonce || requested_username || "\0" || password)
pwhash = hex(truncate_to_top_N_bits(digest))A SCRAM login carries no password, so its pwhash is over the user alone.
With the default 12-bit truncation:
pwhashis 4 hex chars (a leading byte + the top nibble of the second byte, masked off)- 4096 possible distinct values for the entire user base
- a single user's
pwhashis stable across attempts with the same password, changes on password change - rate-limiters can count reused-vs-novel passwords per IP without ever seeing plain text
If your policy server needs higher precision, set auth_ to 24, 48, or 0 (full digest). Anything above ~32 bits starts to leak which-user-has-which-password information; pick the smallest value your detection logic tolerates.
Response
{
"status": 0,
"msg": "optional message for the client"
}status == 0: allow.status < 0: refuse. Amsgis shown to the client: IMAP answersNO [AUTHENTICATIONFAILED] <msg>, POP3-ERR [AUTH] <msg>, Submission535 5.and ManageSieve7. 8 <msg> NO (AUTHENTICATIONFAILED) "<msg>". Without one, the client gets the usual authentication failure.status > 0: tarpit,yarilo-authwaitsstatusseconds before the login goes on.
A 2xx HTTP status with malformed JSON or missing status field is treated as a failover per auth_.
Tarpit
Only the check before the login tarpits. The check after a successful login can refuse it but never delays it.
A login process whose peer is in general. is a proxy in front of yarilo: its logins are not tarpitted and do not count towards the auth penalty.
Master-user exemption
Master-user impersonation (SASL PLAIN with non-empty authzid) bypasses the policy server entirely. Admin sessions are not subject to user-facing abuse policy. This matches the policy exemption applied to the cross-pod penalty store.
Observability
yarilo-auth exports:
| Metric | Labels |
|---|---|
yarilo_ | check (before, after), result (ok, delayed, refused) |
yarilo_ | check |
yarilo_ |
The auth: ok and auth: fail log lines carry policy_ and policy_, the tarpit seconds, when a check ran.
wforce
wforce Integration deploys weakforced as the policy server, with a policy against password guessing and the steps to turn it on.
Rollout strategy
- Shadow mode (week 1) - set
auth_, watch the logs for would-have-rejected events. Logins are neither delayed nor refused. Tune policy server thresholds until false-positive rate is acceptable.policy_ log_ only: true - Soft enforce (week 2) - flip
auth_, keeppolicy_ log_ only: false auth_(fail-open). A policy server outage does not affect logins.policy_ reject_ on_ fail: false - Hard enforce (week 3+) - flip
auth_once the policy server has proven HA. A policy server outage now blocks logins; pair this with a monitored healthcheck.policy_ reject_ on_ fail: true
Tuning notes
auth_+policy_ check_ before_ auth auth_doubles the per-attempt latency. Most operators disablepolicy_ check_ after_ auth auth_oncepolicy_ check_ after_ auth auth_is trusted;policy_ check_ before_ auth auth_is fire-and-forget so it doesn't add perceived latency.policy_ report_ after_ auth - The HTTP client uses keep-alive - a single yarilo-auth pod reuses connections to the policy server across thousands of requests.
- Set
timeout_shorter thanms auth.so a slow policy server doesn't push the entire wire response past the client timeout.auth_ failure_ delay