pam-device-auth adds browser-based sign-in as a second factor to SSH.
A user first proves possession of an SSH key. They then approve the login with your identity provider in a browser. Access is granted only when both steps succeed. Users, groups, SSH keys and sudo permissions stay in your central LDAP directory instead of being managed separately on every server.
It is built for teams that want to:
- manage Linux SSH access centrally;
- require an SSH key and company sign-in for every non-root login;
- grant or revoke access through directory groups;
- provision key-only service accounts for automation, in a dedicated directory group, without the OIDC step;
- use the directory password for sudo, or grant passwordless sudo to selected groups;
- optionally restrict users to approved source IPs, enforced for key-based service accounts too;
- keep a local root SSH key as an emergency path if LDAP or the identity provider is unavailable.
The tested setup is Keycloak with LLDAP.
Other providers can be used when they support the Device Authorization Grant,
serve discovery/token/JWKS endpoints from the issuer host, and issue an access
token with preferred_username, the configured role claim, and either azp or
a matching aud. Templates for Auth0, Okta and Authentik are included.
- Ubuntu 24.04+, Debian 13+, Rocky Linux 9 or 10, or a compatible amd64 Linux host with OpenSSH 9.6+.
- An LDAP directory containing each user's Unix ID, group membership and SSH public key.
- An OIDC provider with the Device Authorization Grant enabled, such as Keycloak, Auth0, Okta or Authentik.
- A root SSH-key session during setup, so you can recover from configuration mistakes.
pam-device-auth configures SSSD and OpenSSH for this model. It does not replace LDAP or your identity provider.
- The user runs
ssh user@host. - OpenSSH checks the user's public key from LDAP.
- pam-device-auth displays a URL, code and optional QR code.
- The user approves the login with the identity provider.
- pam-device-auth verifies the user, required role and optional IP restriction.
- The shell opens and the home directory is created if needed.
Later logins from a known IP can use a securely cached refresh token, so the user
does not need to open the browser every time. The SSH key is still required on
every login. sudo asks for the user's directory password. Root uses only its
local SSH key and never depends on LDAP or OIDC.
Before installing, prepare:
- LDAP connectivity from the server. Use LDAPS where possible and install the directory CA on the server.
- An access group such as
ssh-accessand an optional sudo group such asssh-admin. uidNumber,gidNumberandsshPublicKeyfor every user who should log in. On unprivileged LXC containers, keep IDs below 65536.- A public OIDC client with Device Authorization enabled and the access role included in its tokens.
The reference deployment is tested with LLDAP 0.6.3. LLDAP does not ship these POSIX/SSH attributes by default, so create them before configuring the first host:
| Schema | Attribute | Type | List | Value |
|---|---|---|---|---|
| Group | gidNumber |
Integer | No | unique Unix group ID |
| User | uidNumber |
Integer | No | unique Unix user ID |
| User | gidNumber |
Integer | No | primary group ID, usually the access-group ID |
| User | sshPublicKey |
String | No | complete OpenSSH public key |
| User, optional | clients |
String | Yes | allowed source IPs or CIDRs |
Create the attributes in the LLDAP Admin UI under Attributes or through its
GraphQL API. Set gidNumber on the access group (for example 6000) and the
sudo group (for example 6001). Every group you reference from the config needs
a gidNumber, including the optional service-account group (ssh-service, for
example 6002) and passwordless-sudo group (ssh-admin-nopasswd, for example
6003). A group without a gidNumber does not resolve through SSSD, so the sshd
Match Group block and the sudoers rules never match and its members are locked
out. Set each user's primary gidNumber to the access-group ID, assign a unique
uidNumber, and add the user to the access group. Add administrators to the sudo
group as well.
Use LLDAP's built-in uid for the username and built-in member for group
membership. Groups remain groupOfNames; do not convert them to posixGroup.
The SSSD configuration generated by pam-device-auth uses the rfc2307bis
membership model.
You do not need to add homeDirectory or loginShell: --setup-ldap sets the
home to /home/<username> and the shell to /bin/bash. On unprivileged LXC
containers, keep all UIDs and GIDs below 65536.
Install via the APT repository (recommended; pulls sssd-ldap,
libnss-sss, libpam-sss, and sssd-dbus as recommended dependencies):
sudo install -d -m755 /etc/apt/keyrings
curl -fsSL https://apt.nk-it.cloud/gpg.key \
| sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/nk-it-cloud.gpg
echo "deb [signed-by=/etc/apt/keyrings/nk-it-cloud.gpg] https://apt.nk-it.cloud/apt stable main" \
| sudo tee /etc/apt/sources.list.d/nk-it-cloud.list
sudo apt update
sudo apt install pam-device-authOr install a downloaded package manually: sudo apt install ./pam-device-auth_<version>_amd64.deb.
On Rocky Linux 9 or 10, install the RPM release asset instead:
sudo dnf install ./pam-device-auth-<version>.x86_64.rpm.
Configure (see configs/config.json):
sudo install -m600 /dev/stdin /etc/pam-device-auth/config.json <<'JSON'
{
"issuer_url": "https://sso.example.com/realms/myrealm",
"client_id": "ssh-server",
"required_role": "ssh-access",
"sudo_role": "ssh-admin",
"allowed_algorithms": ["RS256"],
"ldap": {
"uri": "ldaps://ldap.example.com:636",
"base_dn": "dc=example,dc=com",
"bind_dn": "uid=nss-ro,ou=people,dc=example,dc=com",
"bind_password": "<read-only bind password>",
"access_group": "ssh-access",
"admin_group": "ssh-admin",
"service_account_groups": ["ssh-service"],
"nopasswd_sudo_groups": ["ssh-admin-nopasswd"]
}
}
JSON
The last two `ldap` fields are optional. `service_account_groups` lists groups
whose members log in with a directory key only, without the OIDC step, for
automation accounts. `nopasswd_sudo_groups` lists groups granted passwordless
sudo. Leave them out if you do not use those tiers.
sudo pam-device-auth --setup-ldap # SSSD + nsswitch + mkhomedir + sudoers + sshd drop-in (+ auto-migrate)
sudo pam-device-auth --check # verify OIDC, SSSD, directory keys and effective sshd policy
sudo pam-device-auth --enable # activate the PAM/sshd config (restarts sshd)Activation order matters: populate every user's
sshPublicKeyin LDAP before--enable, or non-root users cannot satisfy the key factor and are locked out (root is unaffected).Existing local accounts:
--setup-ldapautomatically migrates same-name local accounts that shadow a directory identity. It prints the full scope, terminates those users' processes, removes their local passwd/shadow entries, and preserves/re-owns their home files. Run it from a persistent root session and review the warning before continuing with activation.
--check must report no [FAIL] items before activation. A [WARN] is
non-blocking and may be intentional, for example an access-group member that is
deliberately not provisioned with an SSH key. See the
preflight example for the complete output format.
CLI: --check · --setup-ldap · --enable · --pam-acct (internal, used by the PAM account phase) · --debug · --version · --help
/etc/pam-device-auth/config.json (root-only, 0600):
| Field | Req | Description |
|---|---|---|
issuer_url |
yes | OIDC issuer URL (https) |
client_id |
yes | public OAuth2 client (device grant enabled) |
required_role |
yes | OIDC role required to log in (e.g. ssh-access) |
sudo_role |
no | admin-role label shown by --check; sudo enforcement uses ldap.admin_group |
allowed_algorithms |
no | pin accepted JWT alg (e.g. ["RS256"] / ["ES256"]); empty = any supported |
role_claim |
no | extra dotted/flat claim path for roles (supplements Keycloak realm/client roles) |
auth_timeout |
no | device-flow timeout, 30-240 s (default 180) |
show_qr |
no | true/false/omit (auto-detect client) |
ldap |
setup/check | directory settings for --setup-ldap and directory preflight: uri, base_dn, bind_dn, bind_password, access_group, admin_group; optional user_search_base, group_search_base, service_account_groups, nopasswd_sudo_groups |
The ldap.bind_password is read by --setup-ldap and written into the root-only
/etc/sssd/sssd.conf for SSSD. Environment overrides exist only for
issuer_url, client_id, required_role, sudo_role, role_claim, and
auth_timeout through PAM_DEVICE_AUTH_ISSUER,
PAM_DEVICE_AUTH_CLIENT_ID, PAM_DEVICE_AUTH_REQUIRED_ROLE,
PAM_DEVICE_AUTH_SUDO_ROLE, PAM_DEVICE_AUTH_ROLE_CLAIM, and
PAM_DEVICE_AUTH_TIMEOUT.
Two group axes are independent of each other:
- SSH auth:
ldap.access_groupgets the normal key + OIDC login; any group listed inldap.service_account_groupslogs in with the SSH key only, no OIDC step. This is for automation and other service accounts. A user needs membership in one of these two kinds of groups to log in at all. - sudo:
ldap.admin_groupgets password sudo throughpam_sss; any group listed inldap.nopasswd_sudo_groupsgets passwordless sudo (NOPASSWD:ALL), written to/etc/sudoers.d/pam-device-authby--setup-ldap.
Any new LLDAP group used in these lists needs a POSIX gidNumber, or SSSD
will not resolve it.
The optional clients LDAP attribute holds a per-user list of source
IPs/CIDRs. It is enforced on every login, including key-only service
accounts, by the PAM account phase (--pam-acct), which reads clients for
the logging-in user from the SSSD InfoPipe over busctl. Root and other
local (non-directory) users are exempt, since break-glass access must not
depend on SSSD.
This check fails closed: if the InfoPipe lookup for a directory user errors
out, that login is denied. An SSSD or InfoPipe outage therefore denies all
directory users, not just the ones with a clients value set. --check
reports both the IP-pin posture and InfoPipe health so this can be caught
before it happens in production.
On Debian and Ubuntu, the InfoPipe (ifp) responder that --setup-ldap
configures needs the sssd-dbus package, which --setup-ldap installs. On
RHEL-family systems it ships inside sssd-common.
If a local account has the same name but a different UID from a directory user,
--setup-ldap prints the complete migration scope, terminates that user's
processes, removes the local passwd/shadow entry with userdel, and re-owns the
existing home, mail spool and crontab to the directory UID/GID. It does not
delete the home directory. Genuine local-only accounts and same-UID entries are
left unchanged.
- 2FA: directory SSH key and OIDC, re-validated every login (signature, issuer,
azp/aud,exp/nbf/iat, required role). Algorithm/key-type binding (RS/ES 256/384/512),kidrequired, nonone/HMAC. - Source-IP allowlist: the per-user
clientsattribute is enforced in the PAM account phase for every login, including key-only service accounts. Only root is exempt. See docs/ip-allowlist.md. - Directory revocation: remove a user from the access group and its mapped OIDC access role. New logins are denied after SSSD and identity-provider federation/cache updates. Existing SSH sessions are not terminated.
- sudo: members of
ldap.admin_groupauthenticate with the directory password throughpam_sss; members ofldap.nopasswd_sudo_groupsgetNOPASSWD:ALLinstead. No local passwords or/etc/shadowto drift. - C PAM shim: fork/exec (no shell),
clearenv+ whitelisted env,pipe2(O_CLOEXEC),strtok_r, SIGPIPE handling, per-call timeout, log sanitization. - Cache: refresh token in tmpfs
/run/pam-device-auth/(0700 root:root, cleared on reboot); known-IP list capped (FIFO). The silent cached-refresh path still requires the SSH key factor. - Root break-glass: key-only, independent of OIDC/SSSD/LDAP availability.
- Uninstall: removing the package restores the original
/etc/pam.d/sshd, removes the sshd drop-in, and deletes the managed/etc/sudoers.d/pam-device-authfile, so noNOPASSWDgrant is left behind. This applies to both the.deb(dpkg) and the.rpm(dnf/rpm) package.
See SECURITY.md for the threat model and reporting.
# Go 1.26+, GCC, libpam0g-dev
sudo apt install build-essential libpam0g-dev
make build-all # Go helper (CGO-free) + C PAM module
make test # tests with race detector
make deb # Debian package, built with nfpm from nfpm.yaml
make rpm # RPM package, from the same nfpm.yamlSee CONTRIBUTING.md for the development workflow.