Skip to content

Authentication Guide

Bifrost authenticates connections using an ordered list of auth providers. Each provider declares a plugin type, an optional priority (lower numbers are tried first), and a config map with the plugin-specific settings.

Schema note: The legacy auth.mode field and the top-level typed blocks (auth.native, auth.ldap, auth.oauth, auth.system, …) are no longer supported and the server rejects them at startup. Use auth.providers[] with an explicit type and a nested config map, as shown below.

This page is the reference for every provider type and the shape of its config map. The companion Advanced Authentication guide does not duplicate that schema — it covers the enterprise deployment side of Kerberos, NTLM, mTLS and SPNEGO: keytab and CA creation, krb5.conf, browser and curl client setup, the auth.negotiate middleware, and troubleshooting. Where the two pages describe the same option they use the same keys; if they ever disagree, the plugin source under internal/auth/plugin/ is the authority.

A provider that accepts configuration and then rejects every login is indistinguishable, to the person trying to log in, from a wrong password. So Bifrost refuses to run one rather than let it fail quietly. Two states are treated differently, because they are different problems:

State Meaning Result
unimplemented The plugin can never authenticate anyone in any build — ntlm cannot verify an NTLM response Server refuses to start
build_disabled This binary lacks what the plugin needs (a build tag, cgo, a platform library), but another build has it — system without the pam tag on Linux Starts, with a warning logged for that provider

Note where the refusal happens: the config file itself parses and validates normally, so bifrost-server validate and the dashboard’s config editor accept it. The provider is rejected when the authenticator is constructed, which is during server start:

create authenticator: provider "corp-ntlm": auth provider type "ntlm" cannot
authenticate anyone and is refused: NTLM response verification is not
implemented: there is no credential source (NT-hash store or domain-controller
pass-through) to verify a client's NTLMv2 response against, so every login is
rejected. Use the 'kerberos' provider for Windows-domain SSO, or 'ldap' against
Active Directory. [...]

The remedy is to remove the provider, or set enabled: false on it — a disabled provider is never constructed. For Windows domain single sign-on use kerberos with the auth.negotiate middleware, which is functional; see the Advanced Authentication guide.

build_disabled deliberately does not block startup: the same configuration is valid on a build that includes the support, so refusing it would make one config file un-shareable across builds. It is logged at WARN instead:

auth provider cannot authenticate in this build; every login through it will be
rejected provider=sys type=system state=build_disabled

To see what the running binary actually supports, ask it — GET /api/v1/auth/providers reports an availability state per plugin, and POST /api/v1/config/validate reports a refused provider as a validation error. Both are documented in the server API reference.

Default behaviour - allows all connections.

auth:
providers:
- name: open
type: none
enabled: true
priority: 1

Username/password authentication with bcrypt hashes.

auth:
providers:
- name: native
type: native
enabled: true
priority: 1
config:
users:
- username: admin
password_hash: "$2a$10$..."
groups:
- admins
email: admin@example.com
full_name: "Admin User"
- username: user1
password_hash: "$2a$10$..."
disabled: false

Using the bcrypt tool:

Terminal window
# Using htpasswd
htpasswd -bnBC 10 "" password | tr -d ':\n'
# Using Python
python3 -c "import bcrypt; print(bcrypt.hashpw(b'password', bcrypt.gensalt()).decode())"
# Using Go
go run -mod=mod github.com/rennerdo30/bifrost-proxy/tools/hashpw password

Authenticate against an LDAP directory.

auth:
providers:
- name: ldap
type: ldap
enabled: true
priority: 1
config:
url: "ldap://ldap.example.com:389"
base_dn: "dc=example,dc=com"
bind_dn: "cn=service,dc=example,dc=com"
bind_password: "${LDAP_BIND_PASSWORD}"
user_filter: "(uid=%s)"
group_filter: "(memberUid=%s)"
require_group: "proxy-users"
tls: false
insecure_skip_verify: false
Field Default Description
url required LDAP server URL
base_dn required Base DN for searches
bind_dn - DN for the service account
bind_password - Service account password
user_filter (uid=%s) Filter to find users (%s = username)
group_filter - Filter to find user groups
require_group - Only allow users in this group
user_attribute uid Attribute holding the username
email_attribute mail Attribute holding the email address
full_name_attribute cn Attribute holding the display name
group_attribute cn Attribute holding the group name
tls false Use LDAPS/TLS
insecure_skip_verify false Skip TLS certificate verification
group_lookup_fail_closed false Reject the login when the group lookup itself fails

Both url and base_dn are required — the provider fails to start without them.

Authenticate against the operating system’s user database (PAM on Linux, Directory Services on macOS).

Warning: Platform Support System authentication is only supported on Linux and macOS. Windows is not currently supported. If you need authentication on Windows, use native, ldap, or oauth instead.

auth:
providers:
- name: system
type: system
enabled: true
priority: 1
config:
service: "login" # PAM service name (Linux only)
allowed_users: # Optional: restrict to specific users
- alice
- bob
allowed_groups: # Optional: restrict to specific groups
- admin
- staff
Platform Support Method
Linux ⚠️ Requires custom build PAM (only in CGO_ENABLED=1 -tags pam builds; the default/Docker build fails closed)
macOS ✅ Supported Directory Services (dscl)
Windows ❌ Not Supported Use native/ldap/oauth instead

Warning: The default (and Docker) binaries are built without the pam tag and with cgo disabled, so Linux system authentication fails closed — every login is rejected. To enable it, build from source with CGO_ENABLED=1 go build -tags pam ./... (requires libpam headers, e.g. libpam0g-dev / pam-devel). macOS does not need a special build.

Field Description
service PAM service name (default: login) - Linux only
allowed_users Optional list of allowed usernames
allowed_groups Optional list of allowed groups (user must be in at least one)
  • Linux: Requires a binary built with CGO_ENABLED=1 -tags pam and the PAM development headers. The process must be able to authenticate against the configured PAM service (often needs root or a suitable /etc/pam.d entry). Without the pam build tag the plugin fails closed.
  • macOS: Requires access to Directory Services via dscl

Authenticate using OAuth 2.0 or OpenID Connect.

auth:
providers:
- name: oauth
type: oauth
enabled: true
priority: 1
config:
provider: "generic"
client_id: "${OAUTH_CLIENT_ID}" # Required
client_secret: "${OAUTH_CLIENT_SECRET}"
issuer_url: "https://auth.example.com"
# Optional explicit endpoints; otherwise derived from issuer_url
# introspect_url: "https://auth.example.com/oauth2/introspect"
# userinfo_url: "https://auth.example.com/oauth2/userinfo"
scopes:
- openid
- profile
- email
# required_claims:
# tid: "your-tenant-id"

required_claims gates every validated token on exact claim values: a missing claim fails, strings compare exactly, booleans and numbers compare by their canonical text form ("true", "42"), an array claim (aud-style) matches when the expected value is one of its string elements, and an object-valued claim never matches. Enforcement covers both validation paths (introspection and userinfo). Leaving the setting out — or empty — enforces nothing.

client_id is required, as is at least one of issuer_url, introspect_url or userinfo_url. There is no redirect_url: Bifrost validates a bearer token the client already holds (sent as the proxy password) rather than running a browser redirect flow, so no callback URL is involved.

Providers are tried in priority order until one accepts the credentials. This allows, for example, a static admin account alongside LDAP:

auth:
providers:
- name: break-glass
type: native
enabled: true
priority: 1
config:
users:
- username: admin
password_hash: "$2a$10$..."
- name: corp-ldap
type: ldap
enabled: true
priority: 2
config:
url: "ldap://ldap.example.com:389"
base_dn: "dc=example,dc=com"
user_filter: "(uid=%s)"

Clients authenticate using Proxy-Authorization header:

Terminal window
curl -x http://user:pass@localhost:7080 http://example.com
Terminal window
curl --socks5 user:pass@localhost:7180 http://example.com

The client can authenticate with the server:

server:
address: "proxy.example.com:7080"
username: "myuser"
password: "mypass"

Authenticate using API keys passed in headers.

auth:
providers:
- name: apikey
type: apikey
enabled: true
priority: 1
config:
header_name: "X-API-Key" # Header to read the key from (default: X-API-Key)
keys:
- name: "service-a"
key_hash: "$2a$10$..." # bcrypt hash of the API key
groups: ["api-access"]
- name: "service-b"
key_hash: "$2a$10$..."
groups: ["api-access", "admin"]
expires_at: "2027-01-01T00:00:00Z" # Optional, RFC 3339
Field Description
header_name Header to read the key from (default: X-API-Key)
keys Required, non-empty list of keys
keys[].name Required identifier, used as the authenticated username
keys[].key_hash bcrypt hash of the key (use this in production)
keys[].key_plain Plaintext key — accepted as an alternative to key_hash
keys[].groups Optional group memberships
keys[].expires_at Optional RFC 3339 expiry timestamp
Terminal window
# Generate a random API key
openssl rand -base64 32
# Hash the key for storage
python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-api-key', bcrypt.gensalt()).decode())"

Validate JWT tokens with JWKS support for key rotation.

auth:
providers:
- name: jwt
type: jwt
enabled: true
priority: 1
config:
issuer: "https://auth.example.com"
audience: "bifrost-proxy"
jwks_url: "https://auth.example.com/.well-known/jwks.json"
# Or pin a static verification key instead of JWKS:
# public_key_pem: "${JWT_PUBLIC_KEY_PEM}" # RSA/EC public key
# hmac_secret: "${JWT_HMAC_SECRET}" # required for HS* algorithms
username_claim: "sub"
groups_claim: "groups"
algorithms:
- RS256
- ES256
Field Default Description
jwks_url - URL to fetch the JSON Web Key Set from
public_key_pem - PEM-encoded RSA/EC public key (alternative to JWKS)
hmac_secret - Symmetric secret for HS256/HS384/HS512
issuer - Expected issuer (iss claim)
audience - Expected audience (aud claim)
algorithms ["RS256"] Allowed signing algorithms
username_claim sub Claim to use as username
groups_claim groups Claim containing user groups
email_claim email Claim containing the email address
leeway_seconds 60 Clock-skew allowance for exp/nbf
jwks_refresh_interval 1h How often to refetch the JWKS

Exactly one key source is required: the provider fails to start unless one of jwks_url, public_key_pem or hmac_secret is set. The allow-list key is algorithms — there is no allowed_algorithms and no signing_key. Listing an HMAC algorithm without hmac_secret is rejected at startup rather than failing on every request.

Time-based One-Time Password authentication, compatible with Google Authenticator.

auth:
providers:
- name: totp
type: totp
enabled: true
priority: 1
config:
issuer: "Bifrost Proxy"
digits: 6 # 6 or 8
period: 30
algorithm: "SHA1" # SHA1, SHA256 or SHA512
skew: 1 # Periods of clock skew to accept
secrets: # A list, not a map
- username: user1
secret: "JBSWY3DPEHPK3PXP" # Base32-encoded
groups: ["staff"]
- username: user2
secret: "GEZDGNBVGY3TQOJQ"
disabled: false
# Or keep secrets out of the main config:
# secrets_file: "/etc/bifrost/totp-secrets.yaml"

secrets must be a list of objects with username and secret keys. A username: secret map is rejected at startup with “secrets must be an array”, and every secret has to be valid Base32.

  1. Generate a secret for each user
  2. Share the secret with the user via QR code or manual entry
  3. User scans with authenticator app (Google Authenticator, Authy, etc.)
Terminal window
# Generate a random TOTP secret
python3 -c "import secrets; import base64; print(base64.b32encode(secrets.token_bytes(20)).decode())"

Counter-based One-Time Password authentication, compatible with YubiKey and similar hardware tokens.

auth:
providers:
- name: hotp
type: hotp
enabled: true
priority: 1
config:
digits: 6 # 6 or 8
algorithm: "SHA1" # SHA1, SHA256 or SHA512
secrets: # A list, not a map
- username: user1
secret: "JBSWY3DPEHPK3PXP" # Base32-encoded
counter: 0
- username: user2
secret: "GEZDGNBVGY3TQOJQ"
counter: 100
look_ahead: 10 # Accept codes within this window
# secrets_file: "/etc/bifrost/hotp-secrets.yaml"

As with TOTP, secrets is a list of objects (username, secret, and for HOTP the starting counter) — a map keyed by username is rejected at startup.

Client certificate authentication for mutual TLS.

auth:
providers:
- name: mtls
type: mtls
enabled: true
priority: 1
config:
ca_cert_file: "/path/to/ca.crt" # or ca_cert_pem for an inline PEM
require_client_cert: true
verify_time: true
# Map certificate fields to the user identity
subject_mapping:
username_field: "CN"
groups_field: "OU"
email_field: "emailAddress"
# Optional: restrict which certificates are accepted.
# These are regular expressions matched against the full subject/issuer
# DN string — not shell globs.
allowed_subjects:
- "^CN=client1\\.example\\.com,.*$"
- ".*OU=Engineering.*"
allowed_issuers:
- ".*CN=Bifrost CA.*"
crl_file: "/path/to/crl.pem"

Exactly one of ca_cert_file / ca_cert_pem is required — the provider fails to start without it. There is no ca_cert, require_cn, allowed_cns or username_from key; CN restrictions are expressed as an allowed_subjects regex. See the Advanced Authentication guide for the full option list, listener-level client_auth setup and certificate generation.

  • Client must present a valid certificate signed by the configured CA
  • Certificate must not be expired (verify_time) or listed in crl_file. A configured crl_file that cannot be read or parsed is a fatal startup error: revocation checking either works or the provider refuses to run. Remove the key to run without revocation checking — it is never disabled implicitly
  • The field named by subject_mapping.username_field (default CN) becomes the username

Enterprise SSO using Kerberos with SPNEGO (HTTP Negotiate).

auth:
providers:
- name: kerberos
type: kerberos
enabled: true
priority: 1
config:
keytab_file: "/etc/krb5.keytab"
service_principal: "HTTP/proxy.example.com"
realm: "EXAMPLE.COM"
strip_realm: true
username_to_lowercase: true

HTTP browser SSO requires the Negotiate middleware. A bare kerberos provider validates username/password credentials. For transparent SPNEGO SSO over the HTTP proxy, also enable the auth.negotiate block and point kerberos_provider at this provider (see the Advanced Authentication guide).

  1. Create a service principal for the proxy
  2. Export the keytab file
  3. Configure the client to use Kerberos (kinit)
Terminal window
# Create service principal (on KDC)
kadmin -q "addprinc -randkey HTTP/proxy.example.com"
kadmin -q "ktadd -k /etc/krb5.keytab HTTP/proxy.example.com"

Not functional — fails closed by design. The NTLM plugin cannot verify NTLM responses: Bifrost has no credential source (password/NT-hash store or domain-controller pass-through) against which to recompute and compare the client’s response. Accepting the attacker-supplied Type 3 message without cryptographic verification would be an authentication bypass, so the plugin rejects every login. It is retained only so a misconfiguration does not silently fall through to another provider. Do not rely on NTLM for access; use Kerberos, native, ldap, or oauth instead.

There is no working NTLM configuration. Any type: ntlm provider will fail every authentication attempt with an “NTLM response verification is not supported” error.

Combine a primary authentication provider with an OTP provider for two-factor authentication. The wrapper itself is a provider whose config embeds the primary and secondary provider definitions.

auth:
providers:
- name: mfa
type: mfa_wrapper
enabled: true
priority: 1
config:
# Primary authentication (username/password)
primary:
mode: native
config:
users:
- username: admin
password_hash: "$2a$10$..."
- username: user1
password_hash: "$2a$10$..."
# Secondary authentication (OTP)
secondary:
mode: totp
config:
issuer: "Bifrost"
secrets:
- username: admin
secret: "JBSWY3DPEHPK3PXP"
- username: user1
secret: "GEZDGNBVGY3TQOJQ"
# How the OTP is carried inside the password field
password_format: separated # "separated" or "concatenated"
separator: ":" # password:123456
mfa_code_length: 6 # Used by "concatenated" to split the tail
mfa_required: always # always | per_user | group_based

Both primary and secondary blocks are mandatory. If either is missing the plugin falls back to resolving providers by name — which it cannot do — and the server refuses to start with “referencing auth providers by name … is not supported”. Each block takes its own mode (the plugin type) and config, and the nested config must satisfy that plugin’s own schema (note the list-shaped secrets above).

Field Default Description
primary required Inline first-factor authenticator (mode + config)
secondary required Inline OTP authenticator; mode is totp or hotp
password_format concatenated How the OTP is embedded in the password
separator : Separator for password_format: separated
mfa_code_length 6 OTP length, used to split a concatenated password
mfa_required always always, per_user or group_based
mfa_users - Usernames requiring MFA (per_user mode)
mfa_groups - Groups requiring MFA (group_based mode)

otp_separator is accepted as a legacy alias for separator (it also implies password_format: separated).

  1. The client sends the username and a password that carries the OTP
  2. The wrapper splits the OTP off the password using password_format (password:123456 when separated, or the trailing mfa_code_length digits when concatenated)
  3. The primary provider validates the password half, the secondary provider validates the OTP
  4. Both factors must pass for authentication to succeed

There is no OTP header. The code is read only out of the password field — X-OTP and similar headers are not consulted, so the OTP has to be appended to the proxy password.

Sessions for the Web UI / API can be stored in memory (default) or in Redis for persistence across restarts and sharing across replicas.

session:
store: redis # "memory" (default) or "redis"
duration: "24h" # Session lifetime (default 8h)
max_sessions_per_user: 0 # 0 = unlimited
cleanup_interval: "5m" # Memory store reap interval (ignored by Redis)
redis:
addr: "127.0.0.1:6379" # Required when store: redis
password: "${REDIS_PASSWORD}"
db: 0
key_prefix: "bifrost:session:"
op_timeout: "5s"
Store Persistence Scaling Use Case
memory No Single instance Development, simple deployments
redis Yes Multi-instance Production, HA deployments

Note: When store: redis is selected, redis.addr is required and the server fails to start if it is empty.

The authentication system uses a plugin architecture. A plugin is a factory: it validates a config map and builds an auth.Authenticator, which is the object that actually checks credentials. Register the plugin from an init() function so it is available by the time the config is loaded.

import (
"context"
"fmt"
"github.com/rennerdo30/bifrost-proxy/internal/auth"
)
func init() {
auth.RegisterPlugin("custom", &customPlugin{})
}
// customPlugin implements auth.Plugin.
type customPlugin struct{}
func (p *customPlugin) Type() string { return "custom" }
func (p *customPlugin) Description() string { return "Example custom authenticator" }
func (p *customPlugin) ValidateConfig(config map[string]any) error {
if _, ok := config["realm"].(string); !ok {
return fmt.Errorf("custom auth config: 'realm' is required")
}
return nil
}
func (p *customPlugin) Create(config map[string]any) (auth.Authenticator, error) {
if err := p.ValidateConfig(config); err != nil {
return nil, err
}
realm, _ := config["realm"].(string)
return &customAuthenticator{realm: realm}, nil
}
func (p *customPlugin) DefaultConfig() map[string]any {
return map[string]any{"realm": "example"}
}
func (p *customPlugin) ConfigSchema() string { return "" }
// customAuthenticator implements auth.Authenticator.
type customAuthenticator struct{ realm string }
func (a *customAuthenticator) Name() string { return "custom" }
func (a *customAuthenticator) Type() string { return "custom" }
func (a *customAuthenticator) Authenticate(ctx context.Context, username, password string) (*auth.UserInfo, error) {
// Validate the credentials; return an error to reject.
return &auth.UserInfo{Username: username, Metadata: map[string]string{"realm": a.realm}}, nil
}

Once registered, select it from the config like any built-in plugin:

auth:
providers:
- name: custom
type: custom
enabled: true
priority: 1
config:
realm: example