Nginx PAM Authentication with MySQL: A Safer Basic Auth

Written by: Bagus Facsi Aginsa
Published at: 17 Apr 2020


Basic Authentication is the quickest way to put a password in front of part of your site, and on Nginx the most common recipe uses PAM (Pluggable Authentication Module) tied to your Linux user accounts. It works, but it carries a nasty hidden risk: you are now exposing your operating system’s login credentials to the public internet.

Think about what that means. Anyone who finds the page can hammer it with guesses, again and again, with no lockout. And if they ever crack a password, they have not just defeated your web gate, they may have valid shell credentials for your server. Reusing OS accounts for web auth turns a minor annoyance into a full system compromise.

There is a safer way to use PAM on Nginx: back it with a MySQL database instead of the OS. The web login then lives in its own table, completely separate from your real system users. A cracked web password gets an attacker into one protected URL, not into your server. This guide walks through the whole setup, including the database table the original recipes always skip.

This tutorial is for sysadmins and developers who already run Nginx and MySQL and want a self-contained Basic Auth gate. If you do not have Nginx yet, see my guide on installing Nginx from source. Familiarity with Nginx location blocks will help, since that is where we apply the protection.


Conceptual Overview

Three pieces cooperate here, and it helps to see how a single request flows through them before touching any config.

When a request hits a protected location, Nginx does not check the password itself. The nginx-extras build ships an auth_pam module that hands the username and password off to PAM, the standard Linux authentication framework. PAM then looks up a service file by name, a small text file in /etc/pam.d/ that says “to authenticate for this service, use this module.”

Normally that module checks the local OS account database. The trick is to point it at a different module, pam_mysql (installed via libpam-mysql), which validates the username and password against a MySQL table you control. If the credentials match a row in the table, PAM reports success, and Nginx serves the page. If not, the browser gets a 401 and shows its login prompt again.

So the chain is:

Browser → Nginx (auth_pam) → PAM service file → pam_mysql → MySQL table

The beauty of this design is separation. Your web users live in a database table with their own hashed passwords, and they have no shell, no sudo, and no presence in /etc/passwd at all.


Prerequisites

Before you start, make sure you have:

  • Nginx installed. If not, run apt install nginx, or build it as shown in my install Nginx from source guide.
  • MySQL (or MariaDB) installed and reachable, locally is fine. Install with apt install mysql-server if needed.
  • Root or sudo privileges. Run sudo su first so file edits and service restarts do not trip over permissions.
  • An Nginx site you can edit, with a virtual host file under /etc/nginx/sites-available/.

Throughout this guide I will protect a location called /secure, store web users in a database named webauth, and use a PAM service named secure-nginx. Swap in your own names as you go.


Step 1: Install the PAM Dependencies

The stock Nginx package does not include the PAM module. The nginx-extras package does, and libpam-mysql provides the bridge from PAM to MySQL:

apt install nginx-extras libpam-mysql

Both packages are still shipped on current distributions, including Ubuntu 24.04 LTS, where nginx-extras carries the auth_pam module and libpam-mysql (version 0.8.2) lives in the universe repository. If apt cannot find libpam-mysql, enable universe first with add-apt-repository universe && apt update.

nginx-extras is a drop-in replacement for the standard Nginx package with extra modules bundled in, so installing it will not break your existing config. On Debian and Ubuntu the module loads automatically, so you do not need a load_module directive.

One honest caveat for 2026: libpam-mysql is a mature, lightly maintained package rather than an actively developed one. It still works reliably for this use case, but treat it as a focused Basic Auth gate, not the foundation for a large user system. The security practices at the end of this guide matter precisely because of that.


Step 2: Create the MySQL User Table

This is the step most tutorials leave out, which is why people get stuck. PAM needs a table to read, so let us create one. Log in to MySQL:

mysql -u root -p

Create a dedicated database and a user table with an email column and a password column:

CREATE DATABASE webauth;
USE webauth;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL
);

Now add a web user. The PAM config we write later uses crypt=3, which tells pam_mysql the stored password is an MD5 hash, so we store the hash rather than the plaintext using MySQL’s MD5() function:

INSERT INTO users (email, password)
VALUES ('[email protected]', MD5('SuperSecret123'));

Finally, create a dedicated MySQL account for PAM with read-only access to just this table, rather than letting PAM connect as root. This limits the damage if the credentials in the PAM file are ever exposed:

CREATE USER 'authuser'@'127.0.0.1' IDENTIFIED BY 'a-strong-db-password';
GRANT SELECT ON webauth.users TO 'authuser'@'127.0.0.1';
FLUSH PRIVILEGES;
EXIT;

You now have a table with one user and a locked-down account that can only read it.


Step 3: Configure the PAM Service

Create a PAM service file whose name matches what we will tell Nginx to use. In this guide that name is secure-nginx:

nano /etc/pam.d/secure-nginx

Add these two lines:

auth required pam_mysql.so user=authuser passwd=a-strong-db-password host=127.0.0.1 db=webauth table=users usercolumn=email passwdcolumn=password crypt=3
account sufficient pam_mysql.so user=authuser passwd=a-strong-db-password host=127.0.0.1 db=webauth table=users usercolumn=email passwdcolumn=password crypt=3

The auth line verifies the password; the account line confirms the account is allowed. Both must point at the same table. Here is what each parameter means, so you can adapt it to your own schema:

  • user is the MySQL account PAM connects as (the read-only authuser from Step 2).
  • passwd is that account’s MySQL password.
  • host is the MySQL server address. Keep it 127.0.0.1 if MySQL runs on the same box.
  • db is the database name (webauth).
  • table is the table holding your users (users).
  • usercolumn is the column matched against the typed username (email).
  • passwdcolumn is the column holding the password hash (password).
  • crypt is how the password is stored. crypt=3 means MD5, matching the MD5() we used when inserting the user. The two must agree, or every login will fail.

Because this file contains database credentials in plaintext, lock down its permissions:

chmod 600 /etc/pam.d/secure-nginx

Step 4: Configure Nginx

Open the virtual host file for your site:

nano /etc/nginx/sites-available/my-site

Add the auth_pam and auth_pam_service_name directives to the location you want to protect. The service name must exactly match the file you created in /etc/pam.d/, in this case secure-nginx:

server {
    # ... existing config ...

    location /secure {
        auth_pam "Restricted Zone";
        auth_pam_service_name "secure-nginx";

        # ... your normal location config, e.g. try_files or proxy_pass ...
    }

    location /user {
        # ... not protected ...
    }
}

auth_pam "Restricted Zone" sets the realm text the browser shows in its login prompt. auth_pam_service_name "secure-nginx" tells the module which PAM service file to consult. With this in place, only requests to /secure require a login; /user and everything else stay open.

Test the configuration before applying it, so a typo does not take the site down:

nginx -t

A syntax is ok / test is successful message means you are clear to reload.


Step 5: Reload and Test

Apply the change with a reload, which is gentler than a full restart because it does not drop existing connections:

service nginx reload

Now confirm it actually works. Request the protected path without credentials and you should be rejected:

curl -i http://localhost/secure

You should see HTTP/1.1 401 Unauthorized and a WWW-Authenticate: Basic realm="Restricted Zone" header. Now try again with the user you created in Step 2:

curl -i -u [email protected]:SuperSecret123 http://localhost/secure

This time you should get HTTP/1.1 200 OK and the page content. If both checks behave as expected, your MySQL-backed authentication is live. Open the URL in a browser and you will get a native login dialog; only credentials present in your users table will get through.


Common Mistakes and Troubleshooting

Every login fails even with the right password. The usual culprit is a crypt mismatch. If you stored the password with MD5(), the PAM file must say crypt=3. If you stored plaintext, it must say crypt=0. The hashing method in the database and the crypt value in the PAM file have to agree exactly.

401 for valid users, and the Nginx error log shows a database error. Check the auth log for the real reason:

tail -f /var/log/nginx/error.log /var/log/auth.log

A line about access denied for authuser means the MySQL account or its grants are wrong. Re-run the GRANT from Step 2 and confirm the host matches (127.0.0.1 versus localhost are treated as different hosts in MySQL).

auth_pam directive is unknown / Nginx refuses to start. You are running the stock Nginx without the PAM module. Install nginx-extras (Step 1) and reload.

The service name does not resolve. If logins always fail and auth.log mentions a missing PAM service, the auth_pam_service_name in Nginx does not match a file in /etc/pam.d/. The names are case-sensitive and must be identical.

Permission denied reading the PAM file. If you tightened permissions to 600, make sure the file is owned by root, since the Nginx worker consults PAM through a root-privileged helper, not as the unprivileged worker user.


Best Practices

Always pair this with HTTPS. Basic Authentication sends the username and password Base64-encoded, which is not encryption. Over plain HTTP anyone on the network can read them. Put TLS in front of any protected location, for example with my guide on securing Nginx with Let’s Encrypt.

Give PAM a read-only database user. Never let the PAM file connect as root. A dedicated account with SELECT on a single table, as we set up in Step 2, means a leaked PAM file cannot be used to alter your database.

Treat MD5 as a minimum, not a goal. crypt=3 (MD5) is widely compatible with pam_mysql but cryptographically weak by modern standards. Keep the password column hashed at the very least, restrict who can read the table, and rely on HTTPS and a database account with minimal privileges as your real defense in depth. Never store plaintext passwords (crypt=0) in production.

Lock down the PAM file. It holds database credentials, so keep it chmod 600 and owned by root, and keep it out of any backup that lands somewhere less protected.

Add rate limiting in front of the login. Basic Auth has no built-in lockout, so an attacker can guess endlessly. Throttle requests to the protected location to slow brute-force attempts; see my guide on configuring rate limiting in Nginx.


Conclusion

You have moved Nginx Basic Authentication off your operating system accounts and onto a dedicated MySQL table. A request now flows from Nginx through PAM to pam_mysql, which checks a hashed password in a database you fully control, and a cracked web password no longer hands anyone a shell on your server. Along the way you created the user table, a locked-down database account, the PAM service file, and the Nginx directives, then verified the whole chain end to end with curl.

From here, harden the setup the way any real login deserves: put it behind HTTPS, add rate limiting to blunt brute-force attempts, and consider managing web users through a small admin script rather than raw SQL. With the database doing the bookkeeping, adding or revoking a user is now a single INSERT or DELETE, completely independent of the people who can actually log in to your machine.