Protecting SSH and Console Logins with pam_authelia

Introduction

pam_authelia is a PAM module that lets anything authenticating through PAM (most commonly OpenSSH, but also login, su, sudo, and any other PAM consumer) delegate credential verification and two-factor challenges to an Authelia server over its existing HTTP API. Nothing on the server side needs to change: pam_authelia calls the same /api/firstfactor, /api/user/info, /api/secondfactor/totp, /api/secondfactor/duo, /api/oidc/device-authorization, and /api/oidc/token endpoints that the Authelia web portal uses.

The module ships as two artifacts that cooperate over a stdin/stdout pipe protocol:

  • pam_authelia.so: a small C shim loaded into the PAM consumer’s process (e.g. sshd). It handles the PAM conversation function (pam_conv) for prompting the user and securely wiping credentials from memory, and delegates everything else to the Go helper.
  • pam_authelia: a Go helper binary that handles every HTTPS request to Authelia, parses responses, orchestrates the 2FA flow, and renders QR codes for the OAuth2 Device Authorization grant.

The split exists because a CGO-based single-binary PAM module would pull the Go runtime into every sshd preauth child, and because a clean process boundary simplifies fork safety and credential zeroisation. Operators do not need to understand the protocol to use the module.

This guide walks through installing pam_authelia, wiring sshd through it, and configuring each supported flow.

Assumptions

This guide makes the following assumptions:

  • Authelia is already set up, running, and reachable over HTTPS from the host where you plan to install pam_authelia. If you are using a self-signed certificate the CA certificate must be available on that host (see the ca-cert option).
  • The user you plan to authenticate already exists in Authelia’s authentication backend and has TOTP, Duo, and/or a Device Authorization OIDC client enrolled, depending on which 2FA flow you plan to use.
  • You have root on the host running the PAM consumer and can edit /etc/pam.d/* and /etc/ssh/sshd_config and reload sshd.

How it works

The flow for a single SSH login looks like this:

sshd ──▶ PAM ──▶ pam_authelia.so ──fork+exec──▶ pam_authelia (Go) ──HTTPS──▶ Authelia
  ▲                     │                              │
  │  pam_conv prompts ──┘                              │
  └─────────────────────────────── SUCCESS / FAILURE ──┘
  1. sshd accepts the TCP connection and hands the authentication over to PAM per its /etc/pam.d/sshd stack.
  2. PAM loads pam_authelia.so, which forks the Go helper (pam_authelia) with the PAM module options passed as CLI flags.
  3. Username and password are sent to the Go helper over a pipe. For auth-level=2FA the password is taken from PAM_AUTHTOK (set by a preceding pam_unix entry) so the user is never prompted by pam_authelia for it.
  4. The Go helper calls Authelia (/api/firstfactor, then optionally /api/user/info and one of the /api/secondfactor/* or /api/oidc/* endpoints) and writes prompt/info/success/failure commands back to the C shim, which surfaces them to the SSH client through pam_conv.
  5. For the Device Authorization flow the helper additionally runs OIDC discovery against the configured Authelia URL and calls /userinfo with the issued access token, to verify the approved identity matches the Linux username. See Device Authorization identity binding for the full check list.
  6. On success the shim returns PAM_SUCCESS to sshd; on failure it returns PAM_AUTH_ERR.

The Go helper never writes credentials to logs, zeroes them from memory after use, and enforces HTTPS-only communication with Authelia.

Installation

pam_authelia is released from github.com/authelia/pam as .deb, glibc tarball, and musl tarball artifacts for amd64, arm, and arm64. Checksums, SBOMs, and GPG signatures are published alongside every release.

Regardless of which channel you use, two files get installed:

FileDestination
pam_authelia/usr/bin/pam_authelia
pam_authelia.so/lib/security/pam_authelia.so (distro-dependent)

The PAM module directory differs between distributions (Debian uses /lib/x86_64-linux-gnu/security/, Alpine uses /lib/security/, Arch uses /usr/lib/security/), so if you are installing manually you may need to locate the directory containing pam_unix.so and install pam_authelia.so alongside it. The packaged installation methods below handle this automatically.

Debian and Ubuntu

The preferred method is to install from the Authelia APT repository, which publishes signed packages for both Authelia itself and pam_authelia. If you have already added the repository to your system you can install with a single command:

sudo apt update && sudo apt install pam_authelia

If you have not added the repository yet, follow the APT Repository setup steps first and then run the command above. The repository is signed with Authelia’s release key and handles upgrades automatically, so you do not need to download .deb files manually.

Alternatively, you can download a specific .deb release directly from github.com/authelia/pam and install it with apt install ./<file>.deb; useful for pinning a particular version or for hosts that cannot reach the APT repository.

Arch Linux

Three community packages are maintained in the Arch Linux AUR, covering every preference:

PackageWhat it installsWhen to pick it
pam_autheliaBuilds from the latest tagged release tarball on your build hostPreferred for reproducible builds
pam_authelia-binInstalls the prebuilt upstream binary artifact as-isFastest install, no local toolchain needed
pam_authelia-gitTracks the master branch of github.com/authelia/pamFollowing unreleased changes or testing patches

Install whichever suits you using your AUR helper of choice, for example with paru:

paru -S pam_authelia-bin

or with yay:

yay -S pam_authelia-bin

All three packages install the same file layout, so the PAM configuration examples later in this guide apply unchanged.

Alpine Linux

Download the -musl tarball from the github.com/authelia/pam releases page and extract the two files into place:

curl -LO https://github.com/authelia/pam/releases/latest/download/pam_authelia-v0.1.0-linux-amd64-musl.tar.gz
tar -xzf pam_authelia-v0.1.0-linux-amd64-musl.tar.gz
sudo install -m 0755 pam_authelia /usr/bin/pam_authelia
sudo install -m 0644 pam_authelia.so /lib/security/pam_authelia.so

Other Linux distributions (generic tarball)

For glibc distributions without a native package, use the glibc tarball instead of -musl:

curl -LO https://github.com/authelia/pam/releases/latest/download/pam_authelia-v0.1.0-linux-amd64.tar.gz
tar -xzf pam_authelia-v0.1.0-linux-amd64.tar.gz
sudo install -m 0755 pam_authelia /usr/bin/pam_authelia
sudo install -m 0644 pam_authelia.so "$(dirname "$(find /lib /usr/lib -name pam_unix.so -print -quit)")/pam_authelia.so"

The find | dirname dance picks up whichever PAM module directory your distribution uses by locating the well-known pam_unix.so file.

Building from source

If none of the packaged install channels above fit your platform, both artifacts can be built directly from the github.com/authelia/pam repository. You will need:

  • Go 1.26 or newer
  • gcc (or any C11-capable compiler that understands -fstack-protector-strong and -D_FORTIFY_SOURCE=3)
  • make
  • libpam development headers: libpam0g-dev on Debian/Ubuntu, linux-pam-dev on Alpine, pam is included in the base system on Arch

Clone the repository:

git clone https://github.com/authelia/pam.git
cd pam

Build the Go helper binary. The flags match Authelia’s own release build: -trimpath strips local paths from the binary, -ldflags '-s -w' strips the symbol table and DWARF debug information for a smaller binary. CGO_ENABLED=0 is deliberate; the Go helper does not link against libc and is safe to build as a static binary:

CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o pam_authelia ./cmd/pam_authelia

Build the C shim. The shim/Makefile handles the hardening flags for you (-fstack-protector-strong, -D_FORTIFY_SOURCE=3, full RELRO, -z now, -fPIC, -fno-plt on Linux) and detects .so vs .dylib based on the host platform:

make -C shim

Install both artifacts. The pam_authelia.so destination depends on your distribution; use find to locate the directory that already contains pam_unix.so:

sudo install -m 0755 pam_authelia /usr/bin/pam_authelia
sudo install -m 0644 shim/pam_authelia.so \
    "$(dirname "$(find /lib /usr/lib -name pam_unix.so -print -quit)")/pam_authelia.so"

Confirm both files are in place and have the expected modes before adding pam_authelia.so to your PAM stack:

ls -l /usr/bin/pam_authelia
ls -l "$(dirname "$(find /lib /usr/lib -name pam_unix.so -print -quit)")/pam_authelia.so"

SSH server prerequisites

pam_authelia uses the PAM keyboard-interactive conversation to prompt for passwords and 2FA codes, so sshd must be configured to use PAM and to permit keyboard-interactive authentication. The minimum /etc/ssh/sshd_config looks like this:

/etc/ssh/sshd_config
UsePAM yes
KbdInteractiveAuthentication yes
PasswordAuthentication no
AuthenticationMethods keyboard-interactive

If you plan to use the OAuth2 Device Authorization flow you may want to consider LoginGraceTime. The default of 2 minutes is usually enough for users to scan the QR code and approve on their phone, but if you see logins timing out while users are still mid-approval you can raise it:

/etc/ssh/sshd_config
LoginGraceTime 5m

Reload sshd after editing the file:

sudo systemctl reload sshd

PAM module options

The following options can be supplied to pam_authelia.so in any PAM stack file (commonly /etc/pam.d/sshd). Every option is a key=value pair except for the boolean debug flag which takes no value. Option names are case-sensitive and use kebab-case.

The required badge on each option below uses one of three values, matching the convention used elsewhere in the Authelia documentation:

  • yes: the option must be set; the module will refuse to authenticate without it.
  • no: optional; the shown default applies when the option is omitted.
  • situational: required only under specific configurations (for example oauth2-client-id is required when method-priority contains device_authorization, otherwise it is ignored).

Options

url

string required

The URL of the Authelia server. Must use the https:// scheme. This is the base URL the Go helper uses for every API call, for example POSTing to /api/firstfactor.

Example:

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com auth-level=1FA+2FA

auth-level

string 1FA+2FA not required

The authentication level to enforce. Must be one of 1FA, 2FA, or 1FA+2FA (case-sensitive). Each level is described in full under Authentication flows:

  • 1FA: password only, validated against Authelia’s first-factor endpoint.
  • 2FA: the password is read from PAM_AUTHTOK (set by a preceding module such as pam_unix.so), Authelia is queried silently for 1FA, and the user is prompted for the second factor.
  • 1FA+2FA: the user is prompted for a password, and upon success is prompted for the second factor.
string authelia_session not required

The name of the session cookie Authelia issues on successful 1FA. Must match the server-side session.cookies[].name value in your Authelia configuration. Only change this if your Authelia deployment has a non-default session cookie name.

ca-cert

string not required

Path to a custom CA certificate (PEM-encoded) used to verify Authelia’s TLS certificate. Defaults to the system trust store. Use this when Authelia is served behind a private CA:

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.internal ca-cert=/etc/ssl/certs/internal-ca.pem

The file must be readable by the user sshd drops privileges to during authentication (typically root for the PAM preauth child).

timeout

integer 60 not required

Upper bound in seconds on the entire PAM exchange, including time spent waiting for user input and for Authelia responses. When the timeout fires the C shim kills the Go helper and returns PAM_AUTH_ERR to sshd.

The default of 60 seconds is comfortable for password and TOTP flows and usually sufficient for the Device Authorization flow too, especially if users approve on a phone they already have to hand.

Note

If you see sshd aborting Device Authorization logins before the Go helper has finished polling (for example because users take longer than 60 seconds to find their phone before even starting the approval), raise this option on the pam_authelia.so line in /etc/pam.d/sshd:

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com timeout=300 \
    method-priority=device_authorization oauth2-client-id=pam-authelia

This option only governs the PAM-side exchange. It is unrelated to Authelia’s own device code expiry (configured server-side via identity_providers.oidc.lifespans.device_code). If your users hit device authorization token expired that’s an Authelia-side timeout and raising this PAM option will not help. See Troubleshooting for how to handle that case.

binary

string /usr/bin/pam_authelia not required

Absolute path to the pam_authelia Go helper binary. Override only if you installed the binary somewhere non-standard (for example when building from source and installing under /opt/ or /usr/local/bin/).

method-priority

string not required

A comma-separated list of 2FA method identifiers the module should try, in order. Valid entries are totp, mobile_push, device_authorization, and the special user keyword. The first entry whose method is usable for the current user is selected; if none match, authentication fails.

When this option is omitted the module uses whichever 2FA method Authelia has stored as the user’s preference. See Method priority and the user entry for worked examples.

oauth2-client-id

string situational

OAuth2 client ID for the Device Authorization grant. Required when method-priority contains device_authorization; ignored otherwise. Must match a client configured on the Authelia side with grant_types: ['urn:ietf:params:oauth:grant-type:device_code']. See Configuring Authelia for the Device Authorization flow for the server-side setup.

oauth2-client-secret

string situational

OAuth2 client secret. Required when the client referenced by oauth2-client-id is a confidential client (i.e. configured with token_endpoint_auth_method: 'client_secret_post' on the Authelia side). Omit this for public clients.

Important Note

This secret appears in cleartext in /etc/pam.d/* and will be visible to anyone with read access to those files. On most distributions /etc/pam.d/* is already 0644 and owned by root, but verify that the file is not world-readable if you consider the device-flow client secret sensitive, or use a public client (no secret) instead.

oauth2-scope

string openid,authelia.pam not required

Comma-separated OAuth2 scopes to request on the Device Authorization endpoint. The module normalizes the comma-separated form into the space-separated form required by RFC 6749 before sending the HTTP request. Only relevant when the Device Authorization flow is enabled.

Both openid and authelia.pam are mandatory and are enforced at config parse time; the Go helper refuses to start if either is missing. openid is required so Authelia issues an ID token the helper can verify; authelia.pam is the custom scope that grants the authelia.pam.username claim used to bind the issued token to the Linux username the PAM module is authenticating. You can append additional scopes (for example openid,authelia.pam,email) without breaking this contract, but you cannot drop either of the two required ones. See Device Authorization identity binding for the full rationale and the server-side claims_policies and custom-scope configuration that produces the claim.

debug

boolean false not required

A boolean flag with no value; its presence enables debug logging. Diagnostic lines are written to stderr, which sshd captures in its journal (see Troubleshooting for the exact log format and how to read it).

Authentication flows

pam_authelia supports three authentication levels, controlled by the auth-level option, and four 2FA methods, controlled by method-priority. The three levels are:

1FA: password only

The user is prompted for a password. The module POSTs {"username": "...", "password": "..."} to /api/firstfactor and grants the login on HTTP 200 with status: OK. No second factor is ever attempted; this mode is only useful when Authelia is acting as a centralized password store and you do not want two-factor enforcement on PAM logins.

2FA: password from PAM stack, then second factor

The password is taken from PAM_AUTHTOK, which must be populated by a preceding module such as pam_unix.so:

/etc/pam.d/sshd
auth required pam_unix.so
auth required pam_authelia.so url=https://auth.example.com auth-level=2FA

pam_authelia silently POSTs the same credentials to /api/firstfactor (the user is not re-prompted), and upon success prompts for the second factor. This mode is useful when local Unix passwords are the source of truth and Authelia is only consulted for the second factor.

Important Note

Because the password captured by pam_unix.so is forwarded verbatim to Authelia’s first-factor endpoint, the user’s local Unix password must match their Authelia password. If the two drift out of sync the silent 1FA call to Authelia will fail and the login will be rejected even though pam_unix.so already accepted the password. Operators running this mode should either provision the same password in both places when the account is created, or use 1FA+2FA (described below) instead, which prompts the user once and validates only against Authelia.

1FA+2FA: password then second factor

The most common deployment. The user is prompted for their password, the module validates it against /api/firstfactor, and then prompts for the second factor:

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com auth-level=1FA+2FA

Second factor method selection

For 2FA and 1FA+2FA, the module fetches /api/user/info to discover which 2FA methods the user has enrolled, then picks one according to method-priority:

MethodEndpointUser interaction
TOTPPOST /api/secondfactor/totpTypes a 6- or 8-digit code from an authenticator app
Duo pushPOST /api/secondfactor/duoApproves the push on their phone
Device AuthorizationPOST /api/oidc/device-authorization (setup)Scans the QR code or visits the verification URL, completes Authelia login (1FA and 2FA if required), approves the consent prompt, presses Enter
POST /api/oidc/token (poll)

WebAuthn over SSH

pam_authelia cannot drive the direct /api/secondfactor/webauthn flow, because FIDO2 authenticators need USB or NFC access to the client host and the SSH keyboard-interactive channel cannot pass an authenticator ceremony through. Behavior depends on what else the user has enrolled and on the method-priority setting:

  • WebAuthn is the user’s Authelia preference but they also have TOTP or Duo enrolled, and method-priority is unset or contains user (the default), pam_authelia automatically falls through to TOTP, then Duo, then Device Authorization, and authenticates via the first usable method. No operator intervention needed.
  • WebAuthn is the user’s only enrolled method: the direct 2FA path fails, but WebAuthn still works via the Device Authorization flow. At the verification URL the user is logging in through a real browser at the Authelia portal, so WebAuthn (and any other 2FA method Authelia supports) works normally there. Configure method-priority=device_authorization or method-priority=device_authorization,user on the PAM stack.
  • method-priority is set to an explicit list that excludes both user and device_authorization (for example method-priority=totp when the user has only WebAuthn enrolled): authentication fails with no usable 2FA method for this user. Either enroll an additional method on the Authelia side or widen the priority list so the module can fall through.

Method priority and the user entry

When method-priority is omitted, pam_authelia uses whichever 2FA method the user has marked as preferred in Authelia. For most deployments this is the right behavior. For cases where you want the PAM stack to enforce a specific 2FA flow regardless of the user’s preference (for example, “always use the Device Authorization flow on servers in this fleet”), use an explicit priority list.

A priority list is a comma-separated list of method identifiers. The module walks the list top-to-bottom and uses the first one that resolves to a usable method for the current user. Valid entries are:

  • totp: use TOTP if the user has it enrolled.
  • mobile_push: use a Duo push if the user has Duo enrolled.
  • device_authorization: use the OAuth2 Device Authorization grant. Requires oauth2-client-id to be set.
  • user: a special entry that resolves to the user’s Authelia preference at runtime. If that preference is WebAuthn (unsupported over SSH) or empty, the module falls back through TOTP, Duo, and Device Authorization in that order.

Worked examples:

Priority listBehavior
totpAlways TOTP; fail if the user has not enrolled TOTP.
totp,mobile_push,userPrefer TOTP, then Duo push, then fall back to whatever Authelia stores as the preference.
device_authorization,userPrefer the Device Authorization flow, fall back to the user’s stored preference.
userAlways respect the user’s Authelia preference (identical to the default behavior).

Example PAM configurations

The following examples all target /etc/pam.d/sshd. Replace the url= hostname with your Authelia deployment. Each example is self-contained and can be used unmodified (except for url=).

1FA only

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com auth-level=1FA
account required pam_permit.so
session required pam_permit.so
/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com auth-level=1FA+2FA
account required pam_permit.so
session required pam_permit.so

2FA only (local password + Authelia second factor)

/etc/pam.d/sshd
auth required pam_unix.so
auth required pam_authelia.so url=https://auth.example.com auth-level=2FA
account required pam_permit.so
session required pam_permit.so

Device Authorization flow

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.example.com \
    auth-level=1FA+2FA \
    method-priority=device_authorization,user \
    oauth2-client-id=pam-authelia \
    oauth2-client-secret=hashed-secret-here \
    oauth2-scope=openid,authelia.pam \
    timeout=300
account required pam_permit.so
session required pam_permit.so

The timeout=300 value gives the user five minutes to approve on their phone before the PAM exchange is torn down. Both scopes (openid and authelia.pam) are required; see oauth2-scope for why, and Configuring Authelia for the Device Authorization flow for the matching server-side setup.

Custom CA (self-signed Authelia)

/etc/pam.d/sshd
auth required pam_authelia.so url=https://auth.internal \
    auth-level=1FA+2FA \
    ca-cert=/etc/ssl/certs/internal-ca.pem
account required pam_permit.so
session required pam_permit.so

Configuring Authelia for the Device Authorization flow

The Device Authorization flow is the only 2FA method that requires additional server-side configuration. Beyond registering an OIDC client with the urn:ietf:params:oauth:grant-type:device_code grant type, you must also define a claims policy that emits the authelia.pam.username claim and a custom OIDC scope (authelia.pam) that grants it. pam_authelia uses the claim to bind the issued token to the Linux username it is authenticating. Without these two pieces the device flow aborts at the new identity-binding check with claim "authelia.pam.username" missing from userinfo response. See Device Authorization identity binding for the full rationale.

Add the following blocks to your Authelia configuration:

configuration.yml
identity_providers:
  oidc:
    claims_policies:
      pam:
        custom_claims:
          authelia.pam.username:
            attribute: 'username'
    scopes:
      authelia.pam:
        claims:
          - 'authelia.pam.username'
    clients:
      - client_id: 'pam-authelia'
        client_name: 'pam_authelia device flow'
        client_secret: '$pbkdf2-sha512$310000$...'
        public: false
        authorization_policy: 'two_factor'
        grant_types:
          - 'urn:ietf:params:oauth:grant-type:device_code'
        token_endpoint_auth_method: 'client_secret_post'
        claims_policy: 'pam'
        scopes:
          - 'openid'
          - 'authelia.pam'

What each block does:

  • claims_policies.pam: a reusable policy named pam with a single custom claim, authelia.pam.username, whose value is sourced from the backend’s username attribute. If your backend’s raw username doesn’t match the Linux account verbatim (different case, an @realm suffix, etc.) anchor the claim at a derived attribute instead; see Case sensitivity and username normalization.
  • scopes.authelia.pam: a custom OIDC scope named authelia.pam that grants the authelia.pam.username claim. The name is arbitrary but must match whatever you send via the PAM oauth2-scope option; authelia.pam is the default the Go helper expects.
  • Client-level claims_policy: 'pam': attaches the pam claims policy to this specific client so the custom claim is actually emitted when a token is issued.
  • Client-level scopes: ['openid', 'authelia.pam']: the client is only allowed to request these two scopes, matching the PAM module’s defaults.

Additional notes:

  • The client_secret value in Authelia’s configuration is a hashed representation; use authelia crypto hash generate to produce one from a random plaintext secret. The cleartext value is what you pass to pam_authelia via oauth2-client-secret.
  • authorization_policy: 'two_factor' forces the device approval itself to require 2FA in Authelia, which effectively gives you two-factor over SSH via the one 2FA challenge the user performs on their phone.
  • If you prefer a public client (no secret in /etc/pam.d/*), set public: true on the Authelia side, omit client_secret there, and omit oauth2-client-secret in the PAM config. The claims_policy + scopes fields still apply.

Once the server-side configuration is in place, reload Authelia and configure the PAM stack as shown in the Device Authorization flow example.

Device Authorization identity binding

The Device Authorization flow has a subtle trust gap that pam_authelia closes explicitly. The OAuth2 token endpoint has no notion of “which local Linux account asked for this code”. If left unchecked, any Authelia account holder who scans a displayed QR code can approve the flow with their own credentials, and the token endpoint will issue a valid access token. Without an identity check, the PAM module would accept that token as proof of authentication and let the approver log in as the requesting Linux user. pam_authelia prevents this by verifying the issued token against the PAM username before returning success.

How the check works

After pam_authelia’s poll of /api/oidc/token returns an access token and ID token, the Go helper runs the following verification steps in order, and fails closed if any step fails:

  1. OIDC discovery against the configured Authelia URL, fetching the JWKs document used to verify signatures.
  2. ID token verification: signature against the discovery-supplied JWKs, issuer, audience (must equal oauth2-client-id), and expiry.
  3. Userinfo request: calls /userinfo under Bearer authentication with the access token.
  4. Token substitution defense: asserts that userinfo.sub == id_token.sub. A mismatch here indicates someone swapped an unrelated access token in for one issued during this device flow.
  5. Username binding: looks up the authelia.pam.username claim in the userinfo response and case-sensitively compares it to the Linux username the PAM shim passed to the Go helper on stdin. Missing claim, wrong type, empty value, or any difference fails the login.

On success the helper writes device identity verified: claim "authelia.pam.username" == pam username "<user>" to the debug log and returns PAM_SUCCESS. On failure it writes a diagnostic line (for example authelia identity "jane" does not match pam username "john") to stderr and returns PAM_AUTH_ERR. There is no partial-success path.

Case sensitivity and username normalization

The comparison is case-sensitive because Linux usernames are. If your Authelia identity store holds usernames in a shape that doesn’t match the Linux account verbatim (mixed case, an @realm suffix, an email, etc.) you must normalize on the Authelia side before the claim is emitted. The cleanest path is to define a derived user attribute via Authelia’s expression engine and anchor the claim at that derived attribute instead of the raw username:

configuration.yml
definitions:
  user_attributes:
    pam_username:
      expression: 'username.lowerAscii()'

identity_providers:
  oidc:
    claims_policies:
      pam:
        custom_claims:
          authelia.pam.username:
            attribute: 'pam_username'

Strip an @domain suffix the same way:

configuration.yml
definitions:
  user_attributes:
    pam_username:
      expression: 'username.split("@")[0].lowerAscii()'

Any expression supported by Authelia’s user attributes engine works; substitute a per-user override, concatenate fields, or combine with other attributes. As long as the resolved value equals the local Linux account name verbatim, the bind succeeds.

Verification

Test each configured flow with a plain ssh command. Use a dedicated non-root user that exists in Authelia; if you lock yourself out of your only administrator account you will need out-of-band console access to recover.

1FA

ssh john@server.example.com

You will be prompted for john’s password. Enter the password you configured in Authelia. A successful login should reach the shell prompt within a second.

1FA+2FA with TOTP

ssh john@server.example.com

You will be prompted for the password, then for a TOTP code. Enter the 6- or 8-digit code from your authenticator app. A successful login should land you at the shell prompt.

Device Authorization flow

ssh john@server.example.com

A QR code will be rendered in your terminal along with the verification URL and user code. Scan the QR code on your phone (or visit the URL on any browser that can reach Authelia), complete the Authelia consent flow, and press Enter in the SSH session. pam_authelia will poll the token endpoint and return you to the shell once Authelia confirms the approval.

Inspecting the debug log

With debug enabled in the PAM config, a successful 1FA+2FA login with TOTP produces log lines similar to:

pam_authelia: POST https://auth.example.com/api/firstfactor
pam_authelia: response status=200 status_field="OK"
pam_authelia: user info method="totp" has_totp=true has_webauthn=false has_duo=false
pam_authelia: selected "totp" (from priority entry "totp")
pam_authelia: POST https://auth.example.com/api/secondfactor/totp
pam_authelia: response status=200 status_field="OK"

On systemd-based distributions these lines are captured by the journal and can be read with:

sudo journalctl -u ssh -t pam_authelia --since '5 minutes ago'

Troubleshooting

Authentication failed with no useful logs

If the SSH client reports Authentication failed and the server journal only shows sshd’s PAM: Authentication failure for user line with nothing from pam_authelia, the debug flag is not enabled. Add debug to the pam_authelia.so line in /etc/pam.d/sshd, reproduce the login, and re-check the journal.

ssh: unable to authenticate, attempted methods [none keyboard-interactive], no supported methods remain

This message from the SSH client means PAM returned PAM_AUTH_ERR early. Check the journal for pam_authelia lines; common causes are:

  • The Go helper binary is missing or at a non-default path. Confirm /usr/bin/pam_authelia exists and is executable, or set binary to the correct path.
  • The CA certificate path in ca-cert is wrong or unreadable; look for failed to read CA certificate on stderr.
  • sshd_config is missing UsePAM yes or KbdInteractiveAuthentication yes.

device authorization response status=401 in the debug log

The oauth2-client-id or oauth2-client-secret does not match what Authelia has configured for the Device Authorization client, or the client is configured as public on the Authelia side but the PAM config is passing a secret (or vice versa). Reconcile the two sides. The quoted string above is the literal log line the Go helper writes; the lowercase form is intentional, since it matches what you will see in journalctl.

claim "authelia.pam.username" missing from userinfo response

Authelia is not emitting the authelia.pam.username claim on the userinfo endpoint, so pam_authelia’s identity check fails closed. Check that:

  1. A claims policy exists with custom_claims.authelia.pam.username defined, anchored at the right backend attribute.
  2. A custom scope named authelia.pam exists and its claims list grants authelia.pam.username.
  3. The OIDC client used by pam_authelia has claims_policy: 'pam' (or whatever you named the policy) attached and has authelia.pam in its scopes list.
  4. The PAM oauth2-scope option includes authelia.pam so the scope is actually requested at device-auth time.

See Configuring Authelia for the Device Authorization flow for the full working YAML.

authelia identity "..." does not match pam username "..."

The user who approved the device flow in the browser is not the same user the PAM module is authenticating. This is pam_authelia’s confused-deputy defense working as intended; it means someone other than the SSH-requesting user attempted to approve the flow, or there is a legitimate case or realm-suffix mismatch between the Linux and Authelia usernames. If it’s the latter, normalize the username on the Authelia side via a derived user attribute and anchor the authelia.pam.username claim at the derived attribute; see Case sensitivity and username normalization.

id token verification failed: ...

The issued ID token did not verify against the Authelia-advertised JWKs. Common causes:

  • The oauth2-client-id in the PAM config does not match the audience of the token Authelia issued (usually because you changed the client ID on one side without the other).
  • Authelia’s JWKs were rotated while a cached discovery document in the Go helper still references the old ones; restart sshd (or whatever PAM consumer caches the helper process) after rotating keys.
  • The PAM url points at a reverse proxy that rewrites the issuer URL on the way through; the iss claim Authelia writes won’t match the discovery document the helper fetches. Put pam_authelia in front of Authelia’s canonical public URL, not an internal host name.

userinfo request failed

The access token was rejected by /userinfo, usually because the custom authelia.pam scope wasn’t actually granted at device-auth time. Double-check that the scope name in the PAM oauth2-scope matches the server-side identity_providers.oidc.scopes entry exactly, and that the client’s scopes list includes it.

--oauth2-scope must include openid / --oauth2-scope must include authelia.pam

Config validation errors from pam_authelia when an operator passes an explicit oauth2-scope that drops one of the two mandatory scopes. Restore both; the defaults already include them, so the simplest fix is usually to remove the explicit oauth2-scope= entirely from /etc/pam.d/sshd.

response status=429 in the debug log

Authelia’s regulation rate-limited the request. Wait for the regulation window to elapse, or tune regulation.max_retries and regulation.find_time on the Authelia side. pam_authelia does not retry rate-limited requests automatically.

Device Authorization flow fails with device authorization token expired

This error comes from Authelia’s token endpoint and means the server-side device code lifetime elapsed before the user approved the flow on their phone. The Go helper is still alive at this point (it is simply relaying the expired_token response Authelia sent back), so raising the PAM timeout option will not help and neither will raising sshd’s LoginGraceTime.

There are two real fixes:

  1. Approve faster on your phone. The default Authelia device code lifetime is 10 minutes, which is usually plenty.
  2. If 10 minutes genuinely is not enough for your users, raise Authelia’s device code lifetime server-side by setting identity_providers.oidc.lifespans.device_code, either globally or on a per-client custom lifespan attached to your Device Authorization client.

The lowercase form of the error string above is intentional; it matches what the Go helper writes to the log.

Security considerations

  • TLS is always verified. There is no insecure or skip-verify mode. Connections to Authelia use TLS 1.2 or later, and verification uses the system trust store or the ca-cert you provide.
  • Credentials never reach logs. Passwords and 2FA tokens are never written to debug output. The debug log records HTTP status codes and the status JSON field from Authelia’s responses, but never request or response bodies.
  • Credentials are zeroed from memory after use via explicit_bzero(3).
  • Device Authorization verification URLs are validated. The URL returned by /api/oidc/device-authorization must use https://, point to the same host as url, and be under 2 KiB. This defends against a compromised or man-in-the-middled Authelia response phishing the user via an attacker-controlled URL rendered as a QR code.
  • Device Authorization tokens are bound to the requesting Linux username. After the token endpoint returns, the Go helper runs OIDC discovery, verifies the ID token, calls /userinfo, asserts that userinfo.sub == id_token.sub, and case-sensitively compares the custom authelia.pam.username claim against the Linux username the shim passed to it. Without this check, any Authelia account holder could approve another user’s QR code and end up logged in as them. See Device Authorization identity binding for the full check list and the required server-side claims_policies plus custom-scope configuration.
  • Client secrets live in /etc/pam.d/*. When using oauth2-client-secret the value appears in plaintext in PAM configuration files. Verify that those files are not world-readable (0644 owned by root is the default on most distributions), or use a public client to avoid the issue.
  • Client disconnect detection. If the SSH client disconnects mid-authentication, the C shim notices via POLLRDHUP on the client socket, kills the Go helper with SIGTERM, and returns PAM_AUTH_ERR. Without this, Device Authorization polling could outlive the SSH session and keep hitting Authelia’s token endpoint until the device code expired.
  • Authelia’s regulation still applies. Rate limiting, IP-based throttling, and any other regulation rules enforced by your Authelia deployment apply to every login attempt through pam_authelia. Operators who previously relied on SSH’s own per-source-IP penalties should verify that Authelia’s regulation config is tuned appropriately.

Limitations

  • No WebAuthn / FIDO2 over SSH. FIDO2 authenticators require direct USB or NFC access to the client device, which cannot be tunneled through sshd’s keyboard-interactive channel.
  • Device-flow QR codes need a Unicode-capable terminal. The QR is rendered with Unicode half-block characters (U+2580, U+2584, U+2588). Terminals that do not render these characters fall back to showing only the verification URL and user code, which still work but defeat the “point your phone at the screen” convenience.
  • WebAuthn users without a fallback will fail. If a user’s only enrolled 2FA method is WebAuthn and method-priority is set to an explicit list that excludes both user and device_authorization, authentication fails. Enroll an additional TOTP or Duo credential, widen the priority list, or route the user through the Device Authorization flow (see the WebAuthn over SSH callout in the Authentication flows section for details).

See also