Skip to content

Authentication configuration

Yarilo authenticates IMAP/POP3/Submission credentials against an ordered chain of passdb entries. The first passdb that returns a definitive result (success or failure for a known user) wins. Unknown users fall through to the next entry.


auth.passdb

A list of passdb entries. Each entry has a driver and a dsn. Order matters — entries are tried left-to-right.

KeyDescription
driverBackend type: sqlite | mysql | postgres | passwd-file.
dsnSQL drivers: driver-specific connection string. ${ENV_VAR} is expanded at startup.
passwd_file_pathpasswd-file driver: path to the user file. ${ENV_VAR} is expanded at startup.
passdb_sql_querySQL: optional custom SELECT for authentication. Defaults to the built-in yarilo_users schema. See Custom queries.
userdb_sql_querySQL: optional separate userdb lookup (home, mail). When unset, userdb fields come from passdb_sql_query.
userdb_sql_iterate_querySQL: optional list-users query for admin tooling.
passdb_default_password_schemeAssumed scheme when stored password has no {SCHEME} prefix and no crypt(3) marker. Default: PLAIN (SQL), CRYPT (passwd-file).
skip_schemaSQL: true to skip CREATE TABLE IF NOT EXISTS yarilo_users on startup — use when connecting to an existing schema.
yaml
auth:
  passdb:
    - driver: sqlite
      dsn: /var/lib/yarilo/users.db

SQL passdb

All three SQL backends share one schema:

sql
CREATE TABLE yarilo_users (
    username  TEXT/VARCHAR(255) PRIMARY KEY,
    password  TEXT/VARCHAR(255) NOT NULL,
    home      TEXT/VARCHAR(255) NOT NULL DEFAULT '',
    mail      TEXT/VARCHAR(255) NOT NULL DEFAULT '',
    enabled   INTEGER/TINYINT(1) NOT NULL DEFAULT 1
);

Yarilo runs CREATE TABLE IF NOT EXISTS on startup — fresh installs work without manual migration. Existing tables are left untouched.

SQLite

Pure-Go driver (modernc.org/sqlite), no cgo. Best for single-node deployments and dev environments.

yaml
auth:
  passdb:
    - driver: sqlite
      dsn: /var/lib/yarilo/users.db

MySQL / MariaDB

DSN format (go-sql-driver/mysql): user:password@tcp(host:3306)/dbname?charset=utf8mb4&parseTime=true.

yaml
auth:
  passdb:
    - driver: mysql
      dsn: "yarilo:${DB_PASSWORD}@tcp(db.internal:3306)/yarilo?charset=utf8mb4"

PostgreSQL

DSN format (pgx): standard postgres://user:password@host:5432/dbname?sslmode=require URL.

yaml
auth:
  passdb:
    - driver: postgres
      dsn: "postgres://yarilo:${DB_PASSWORD}@db.internal:5432/yarilo?sslmode=require"

passwd-file passdb

A flat, colon-separated user file (classic /etc/passwd layout) that serves both passdb and userdb roles — a self-hosted deployment can authenticate and resolve mail storage without any database.

yaml
auth:
  passdb:
    - driver: passwd-file
      passwd_file_path: /etc/yarilo-passwd/passwd
      passdb_default_password_scheme: CRYPT

Kubernetes note. Mount the file from a Secret/ConfigMap into its own directory — do not place it under /etc/yarilo, which is already the rendered-config mount. A subPath file mounted inside that directory fails with not a directory and crash-loops every pod. With the Helm chart, supply the content via extraVolumes / extraVolumeMounts at a distinct path (e.g. /etc/yarilo-passwd) and point passwd_file_path at it:

yaml
extraVolumeMounts:
  - name: passwd-file
    mountPath: /etc/yarilo-passwd
    readOnly: true
extraVolumes:
  - name: passwd-file
    secret:
      secretName: yarilo-passwd-file

File format

One user per line:

user:password:uid:gid:gecos:home:shell:extra_fields
  • Only user and password are required.
  • uid / gid / gecos / shell are parsed for layout compatibility but ignored — yarilo derives privilege drop from config and storage paths from the home template, not per-user uid/gid.
  • home (column 6) populates the userdb home field.
  • extra_fields (column 8 onward) are space-separated key=value pairs. Keys with a userdb_ prefix populate the userdb (userdb_mail, userdb_quota_rule, …); bare keys are passdb-side (allow_nets, nologin, …).
  • Lines that are empty or begin with # or : are skipped.
  • The file is reloaded automatically when its mtime or size changes.

Passwords carry a {SCHEME} prefix or a crypt(3) marker; unmarked values assume passdb_default_password_scheme (default CRYPT = crypt(3) autodetection). The same scheme set as the SQL passdb applies, including {SCRAM-SHA-256} verifiers.

# comment
[email protected]:{BCRYPT}$2b$12$...:1000:1000::/mail/alice::userdb_mail=maildir:~/Maildir userdb_quota_rule=*:storage=2G
[email protected]:{SHA512-CRYPT}$6$salt$hash

static passdb

One shared credential and a set of templated fields applied to every user. Serves both passdb and userdb roles. For tests, single-mailbox installs, and proxy front-ends. Because it matches every username it must be placed last in the chain.

yaml
auth:
  passdb:
    - driver: sqlite
      dsn: /var/lib/yarilo/users.db
    - driver: static            # catch-all — last in the chain
      static_password: "${YARILO_STATIC_PASSWORD}"
      passdb_default_password_scheme: BCRYPT
      fields:
        userdb_home: "/var/vmail/%d/%n"
        userdb_mail: "maildir:/var/vmail/%d/%n/Maildir"
KeyDescription
static_passwordShared password ({SCHEME} prefix or passdb_default_password_scheme). ${ENV_VAR} expanded at startup.
nopasswordtrue accepts any password — for proxy front-ends where the upstream authenticates. Mutually exclusive with static_password.
fieldsTemplated user fields. Values expand %u / %n / %d. userdb_-prefixed keys populate the userdb; bare keys are forwarded on the passdb path (allow_nets, proxy, …).

Proxy front-end (accept any credential, let the backend verify):

yaml
auth:
  passdb:
    - driver: static
      nopassword: true
      fields:
        proxy: "y"
        host: "backend.internal"

Password schemes

The password column accepts a {SCHEME}hash prefix. Without a prefix, the format is autodetected from common crypt(3) markers.

SchemePrefixHash formatNotes
Bcrypt{BCRYPT} / {BLF-CRYPT}$2a$.../$2b$.../$2y$...Recommended for new deployments.
SHA-512 crypt{SHA512-CRYPT}$6$salt$hashLinux user import path.
Plain{PLAIN} / {CLEARTEXT}literalDev only. Never store production passwords in plain text.

Autodetection (no {SCHEME} prefix):

Stored value starts withTreated as
$2a$ / $2b$ / $2y$BCRYPT
$6$SHA512-CRYPT
anything elsePLAIN

Generating a bcrypt hash

sh
htpasswd -nbB [email protected] "topsecret"
# [email protected]:$2y$05$LhJ...

Generating a SHA-512 crypt hash

sh
mkpasswd -m sha-512 -S NaClNaCl topsecret
# $6$NaClNaCl$...

Adding a user (SQLite example)

sh
sqlite3 /var/lib/yarilo/users.db <<EOF
INSERT INTO yarilo_users (username, password, enabled)
VALUES (
  '[email protected]',
  '{BCRYPT}$2y$05$LhJOlnSj4N8u7CC8mvjLeOZjzPGq8GwS9ux/dRrK7uW5UlMnG7r4q',
  1
);
EOF

Multiple passdbs

Yarilo tries each entry in order until one returns a result (OK or fail for a known user). Unknown users are passed to the next entry. Useful for: hot-migrating between databases, or shadowing one source with another for testing.

yaml
auth:
  passdb:
    - driver: sqlite
      dsn: /var/lib/yarilo/legacy.db     # checked first
    - driver: postgres
      dsn: "postgres://...:5432/main"    # falls through to here

Custom queries

passdb_sql_query, userdb_sql_query, and userdb_sql_iterate_query accept any SELECT and can connect yarilo to an existing schema. The query may reference these variables, which are substituted as parameterised values (no string interpolation, no injection risk):

VariableMeaningExample
%uFull username[email protected]
%nLocal part (before @)alice
%dDomain (after @)example.com

Do not quote %u/%n/%d in your YAML. They are rewritten to ? (sqlite/mysql) or $1/$2/$3 (postgres) at runtime. Writing '%u' produces literal '?' which the DB will treat as a string, not a placeholder.

Contract

  • passdb_sql_query must return a password column. Columns are matched by name, not position, so the order is free; use AS aliases to map an existing schema (pw_hash AS password). home, mail and enabled are optional — an absent enabled counts as active. password is the only value used downstream when userdb_sql_query is also set.
  • userdb_sql_query must return: home, mail. Called after a successful auth to fill in mailbox location from an authoritative source.
  • userdb_sql_iterate_query must return one column: username.

PostgreSQL with a BOOLEAN enabled column. The built-in yarilo_users schema declares enabled as INTEGER, so the default queries filter with WHERE enabled = 1. Point yarilo at an existing Postgres schema that types the column BOOLEAN and that clause fails with operator does not exist: boolean = integer — write WHERE enabled = true in your own passdb_sql_query / userdb_sql_query. MySQL is unaffected: its BOOLEAN is TINYINT(1), so = 1 is valid. The Go-side check is dialect-agnostic and accepts 1 / true / t / yes / on either way.

Userdb / passdb extra fields

Beyond home / mail, a lookup may return extra fields — as a SQL column alias, userdb_sql_query output, or an auth-socket key=value / userdb_* pair — that tune per-user mailbox, access, and proxying behaviour. Unset fields fall back to the global config. Comma-separated fields (groups, acl_groups, quota_rule, allow_nets) accept multiple values and merge across repeated assignments. Boolean fields accept 1 / yes / y / true / t / on (case-insensitive). Unknown keys are preserved in Extra; forward_* keys populate the proxy forward map.

Identity

FieldMeaning
username / userCanonical username (overrides the login for master-user flows).
original_userThe login as typed before normalisation.
master_userMaster user when the login used master-user syntax.
login_userLogin user for delegated lookups (equals username otherwise).

System identity & groups

FieldMeaning
uid / gidSystem user/group id for privilege drop.
homeUser home directory.
chrootChroot directory.
system_groups_userUsername override for system group lookup.
groupsSupplementary group names (comma-separated). Matched against group= / group-override= ACL entries.
client_cert_presentBool: TLS client-cert auth was used.

ACL evaluation

FieldMeaning
acl_userOverride the identity used when evaluating ACLs (the reference's acl_user). Typically set on a master-user session so ACL checks resolve as the impersonated user rather than the login. Empty = evaluate as the login user.
acl_groupsGroup names (comma-separated) used alongside acl_user for ACL evaluation.

acl_user / acl_groups only affect non-owner namespaces (shared / public / other-users). A user always has full rights in their own personal namespace regardless of the override.

Mail storage

FieldMeaning
mail / mail_locationPer-user mail location (maildir:~/Maildir, mdbox:…), with :INDEX=, :CONTROL=, :ALT=, :VOLATILEDIR= modifiers.
mail_pathBase mailbox path (derived from mail when unset).
mail_inbox_pathExplicit INBOX path override.
mail_volatile_path / mail_index_path / mail_control_path / mail_alt_pathDirect overrides for the corresponding mail-location modifier (win over modifiers embedded in mail).
mail_volatile_path / mail_index_path / mail_control_path / mail_alt_pathThe reference implementation's names for exactly those four fields, accepted so a userdb query written for it works unchanged.
mail_uid / mail_gidOwnership for mail files, distinct from the system uid/gid.
mailbox_format / mail_drivermaildir | sdbox | mdbox — the storage backend that opens this user's mail. Wins over the driver prefix in mail. A name yarilo does not implement is refused and the prefix stands.
mail_attribute_dictDict URL backing RFC 5464 METADATA.

Each of these paths can carry the same variables as the corresponding configuration key — %u / %{user}, %h / ~/, hash buckets, and the rest. See Path templates.

One value, three places. The four path fields can arrive as a modifier inside mail (:INDEX=…) or as the explicit field (mail_index_path — one spelling now, ours and the reference's alike). An explicit field always beats the modifier, whichever spelling the field uses — so moving a userdb query from one spelling to the other never changes which source wins.

How a userdb answer is applied. One order, for every protocol — IMAP, POP3 and delivery (LMTP, JMAP, FTS, quota-status) share the code that does it:

  1. the separate path fields are expanded against the user and written onto the session;
  2. mail / mail_location is parsed: its driver prefix is stamped, and its modifiers fill in only the paths step 1 left blank;
  3. the explicit driver field (mail_driver / mailbox_format) is stamped last, so it wins over the prefix.

A mail_location that cannot be parsed leaves the user on the globally configured backend rather than failing the login, and a driver name yarilo does not implement is refused with the stamped one left in place. Both are logged.

mailbox_format used to be inert. Before 2.3.133 the field was parsed, carried and reported by the backend API — and never consulted. A userdb answering mdbox opened the user with the globally configured driver. If you set it and saw no effect, that is why; it works now, so check that its value is the one you want before upgrading.

Quota

FieldMeaning
quota_rulePer-user quota rule, e.g. *:storage=5G or *:messages=100000 (repeatable).
quota_over_flagValue marking the user as over quota.

Login control

FieldMeaning
allow_netsAllowed source IP/CIDR list (comma-separated); empty = no restriction.
nologinBool: reject login outright.
noauthenticateBool: auth disabled for this user.
nodelayBool: bypass auth-penalty backoff.
pass_expiredBool: password expired, client must reset.
nopasswordBool: accept any password (passdb).
enabledSQL filter column (WHERE enabled = 1, or = true on a Postgres BOOLEAN); not stored as a field. Optional: an absent column counts as active.

Proxy / director (see DIRECTOR.md)

FieldMeaning
proxy / proxy_maybeBool: proxy this session (unconditionally / only if remote).
host / portUpstream backend address.
destuserUsername to present to the upstream.
proxy_mechSASL mechanism for the upstream login.
proxy_timeoutUpstream connect timeout (seconds).
proxy_redirect_reauthBool: re-auth on redirect.
proxy_nopipeliningBool: disable command pipelining to the upstream.
ssl / starttlsUpstream TLS mode / bool STARTTLS.

Connection limits

FieldMeaning
mail_max_userip_connectionsMax concurrent connections per user+IP.
mail_max_user_connectionsMax concurrent connections per user.

Misc

FieldMeaning
serviceRestrict the entry to a named service.
local_nameTLS SNI name this entry applies to.
forward_*Arbitrary key forwarded to the proxy backend (populates the forward map).
(any other key)Preserved verbatim in Extra.

Example: map an existing schema

yaml
auth:
  passdb:
    - driver: postgres
      dsn: "postgres://yarilo:${DB_PASSWORD}@db.internal:5432/mailapp"
      skip_schema: true
      passdb_sql_query: |
        SELECT pw_hash AS password, maildir AS home, mail_path AS mail, active AS enabled
        FROM mailbox_users WHERE email = %u
      userdb_sql_query: |
        SELECT maildir AS home, mail_path AS mail
        FROM mailbox_users WHERE email = %u
      userdb_sql_iterate_query: |
        SELECT email FROM mailbox_users WHERE active = true
      passdb_default_password_scheme: BCRYPT

Every column an existing schema names differently needs an AS alias: password for the passdb, home and mail for the userdb. A query that returns pw_hash without the alias authenticates nobody, because the lookup is by column name. WHERE active = true rather than = 1 because this schema types the column BOOLEAN; see the note above.

Example: PostfixAdmin

PostfixAdmin's mailbox table already names its password column password, so only active needs an alias. skip_schema keeps yarilo-auth from creating its own yarilo_users table alongside it. A single passdb entry serves both roles: passdb_sql_query answers authentication, userdb_sql_query answers the userdb lookup, and every column the latter returns is mapped by name onto a userdb field, so quota_rule and the rest arrive as aliases.

yaml
auth:
  passdb:
    - driver: mysql
      dsn: "${YARILO_DB_DSN}"
      skip_schema: true
      passdb_default_password_scheme: BCRYPT
      passdb_sql_query: |
        SELECT password, active AS enabled, allow_nets
        FROM mailbox
        WHERE username = %u
      userdb_sql_query: |
        SELECT CONCAT(home, mpath) AS home,
               CONCAT(mbtype, ':~/', maildir,
                      ':INDEX=~/index',
                      ':VOLATILEDIR=/tmp/yarilo-volatile/%2.256Nu/%u') AS mail,
               CONCAT('*:bytes=', quota_bytes, ':messages=', quota_messages) AS quota_rule
        FROM mailbox
        WHERE username = %u
      userdb_sql_iterate_query: |
        SELECT username
        FROM mailbox
        WHERE active = 1
        ORDER BY username

username, password, active, maildir and quota are stock PostfixAdmin; mpath, mbtype, quota_bytes, quota_messages and allow_nets are columns this schema adds. Note that %2.256Nu and the second %u sit inside a quoted SQL string and are left alone — substitution skips quoted sections, so those reach the mail layer as templates, while the bare %u in the WHERE becomes a bound parameter.

On PostgreSQL the same queries work with one token changed:

sql
-- passdb_sql_query, userdb_sql_query: unchanged. CONCAT() exists in PostgreSQL and
-- takes non-text arguments, and a BOOLEAN arriving as "enabled" is read as
-- active either way, because that check happens in Go rather than in SQL.

-- userdb_sql_iterate_query: PostfixAdmin types active as BOOLEAN on PostgreSQL and
-- TINYINT(1) on MySQL, and PostgreSQL rejects boolean = integer outright.
SELECT username
FROM mailbox
WHERE active            -- was: WHERE active = 1
ORDER BY username

Leaving = 1 in place breaks only enumeration: mail keeps flowing while yarilo-admin user list fails, which makes the cause easy to miss.

Example: split passdb across hot/cold sources

yaml
auth:
  passdb:
    # Fast cache table — recent logins, refreshed by app.
    - driver: postgres
      dsn: "postgres://yarilo:${DB_PASSWORD}@cache.internal:5432/auth"
      skip_schema: true
      passdb_sql_query: |
        SELECT pw_hash AS password, '/srv/' || %n AS home, '' AS mail, 1 AS enabled
        FROM auth_cache WHERE email = %u

    # Authoritative store — falls through when not in cache.
    - driver: mysql
      dsn: "yarilo:${DB_PASSWORD}@tcp(db.internal:3306)/billing"
      skip_schema: true
      passdb_sql_query: |
        SELECT password, mail_home AS home, '' AS mail, enabled
        FROM users WHERE email = %u

Postfix SASL integration (auth_service.sasl_listen)

yarilo-auth can expose its SASL auth-client protocol on a second, plain-TCP listener so a fronting MTA (Postfix) can authenticate SMTP users against it — see the main.cf snippet below. The main listener (:9100) uses mTLS and is reserved for yarilo login pods; the SASL listener is plain TCP so Postfix can connect without certificates.

KeyDefaultDescription
auth_service.sasl_listen""Address for the Postfix SASL listener. Empty = disabled. Recommended: :12345.
yaml
auth_service:
  listen: ":9100"
  sasl_listen: ":12345"

Helm:

yaml
components:
  auth:
    saslListen: ":12345"

Postfix main.cf:

smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:[yarilo-auth.<namespace>.svc]:12345
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous

The Kubernetes Service exposes the configured port on the yarilo-auth ClusterIP automatically when saslListen is non-empty — no manual service patch needed.


Testing

Unit tests for the SQL passdb cover SQLite end-to-end (via t.TempDir()). MySQL and PostgreSQL smoke tests are opt-in via env vars and skipped otherwise:

sh
YARILO_TEST_MYSQL_DSN="yarilo:secret@tcp(localhost:3306)/yarilo_test?charset=utf8mb4" \
YARILO_TEST_POSTGRES_DSN="postgres://yarilo:secret@localhost:5432/yarilo_test?sslmode=disable" \
go test ./internal/auth/sql/

These tests require pre-created empty databases (yarilo_test). The schema is auto-created by New().