An MIT-licensed nginx module that authenticates users by PGP signature instead of a password. The server stores only public keys, and the private key never leaves the user’s machine.
A user proves they hold a private key by signing a challenge the server issued. There is no password file, no credential travelling on every request, and nothing on the server worth stealing in order to impersonate someone.
ngx_http_pgp_auth_module is a native nginx module written in C, released as v1.0.0 with Debian packages for Bookworm and Trixie.
This is an introduction to how it works, the testing behind it, and how it compares with auth_basic.
How the login works
A request to a protected location with no valid session returns a small page containing a challenge token. The user signs that token with their key — in Kleopatra, or with gpg –clearsign — and pastes the signed block back.
The module verifies the signature against a keyring of allowed signers, checks the challenge is one it issued and has not expired, and issues a session cookie.
Both tokens are stateless. The challenge and the session are HMAC-SHA256 structures over a server secret rather than rows in a session store:
challenge = v1|<exp>|<nonce>|<mac> mac = HMAC(secret, “v1|<exp>|<nonce>”)
session = <exp>|<fpr>|<mac> mac = HMAC(secret, “<exp>|<fpr>”)

The full login flow. After login, every request is an HMAC check rather than a gpg run.
Any node sharing the secret accepts the same tokens, so a fleet behind a load balancer needs no session replication and no sticky sessions. A single-use nonce store held in Redis, or per node stops an issued challenge being replayed inside its validity window.
The testing behind it
A C module runs inside every nginx worker, and an authentication module decides who gets in. That combination was not something to ship on our own confidence.
The module was reviewed by five independent penetration testers and handed back to them after each round of fixes.
Every finding they raised has been addressed. The reports are attached to the pull request in the repository, next to the commits that answer them, so the work can be read rather than taken on trust.
Alongside that, CI builds the module on both Debian targets with -Wall -Werror, runs an end-to-end suite, rebuilds under AddressSanitizer and UndefinedBehaviorSanitizer, and runs -fanalyzer, cppcheck, Valgrind and two fuzzing harnesses.
Repeated testing is not a guarantee. It means several people looked, and what they found was fixed.
How it compares with HTTP basic auth
Basic auth is not broken so much as narrow. The browser base64-encodes user:password and attaches it to every request. Base64 is encoding, not encryption, so the credential’s protection is entirely TLS.
The server holds a password hash worth stealing, the credential is replayable until it is changed, and there is no per-session state to revoke.
| Property | HTTP basic auth | PGP challenge-response |
| Secret on the server | password hash worth stealing | public keys only |
| Credential sent | on every request | once, as a signature over a server-issued challenge |
| Replay of a captured credential | works until the password changes | challenge is single-use and expires |
| Revoking one user | edit htpasswd, and hope it was not shared | remove the key, or list its fingerprint as revoked |
| Phishable | yes — a password can be retyped anywhere | signing happens locally in the user’s own key tool |
| Shared between people | trivially, and invisibly | requires handing over a private key |
| Enrolment cost | minutes | minutes, if your users already hold PGP keys |
That last row is the honest trade. This design suits teams whose members already use PGP maintainers, security teams, infrastructure groups. For an audience that does not, the enrolment cost is real and an OIDC provider is the better answer.
The module runs after nginx’s access phase, so it layers on top of auth_basic rather than fighting it.
What it costs per request
Signature verification forks gpg, which is the expensive part, so the module keeps it off the common path and, by default, off the worker’s event loop using nginx’s thread pool.
An authenticated request carries a session cookie and costs an HMAC check of about a millisecond, with no gpg run at all.
Only a genuine login pays for the subprocess, at roughly 17–25 ms, and a body that is not a clear-signed message is rejected by a pre-check before gpg is started.
Installing it on Debian
Packages are published for Bookworm and Trixie on amd64, each built against that release’s own nginx 1.22 and 1.26 so the package carries the matching nginx-abi dependency and apt refuses to leave you with an nginx that will not start.
# 1. fetch the archive key and check it BEFORE trusting it
key=$(mktemp)
curl -fsSL https://didi75345.github.io/ngx_http_pgp_auth_module/pgp-auth-archive-keyring.asc -o “$key”
gpg –show-keys –with-fingerprint “$key”
# compare against the fingerprint published on the repository page, then:
sudo gpg –dearmor -o /usr/share/keyrings/pgp-auth-archive-keyring.gpg < “$key”
rm -f “$key”
# 2. add the repository
. /etc/os-release
echo “deb [arch=amd64 signed-by=/usr/share/keyrings/pgp-auth-archive-keyring.gpg] \
https://didi75345.github.io/ngx_http_pgp_auth_module $VERSION_CODENAME main” \
| sudo tee /etc/apt/sources.list.d/pgp-auth.list
# 3. install
sudo apt update && sudo apt install libnginx-mod-http-pgp-auth
sudo systemctl reload nginx
The project is explicit about the limit of that check: the key, its fingerprint and the packages are served from the same site, so comparing them catches tampering in transit but not a compromise of the publishing pipeline.
Protecting a location takes three directives:
location /admin/ {
pgp_auth on;
pgp_keyring /etc/nginx/pubkeys.gpg;
pgp_session_secret /etc/nginx/pgp_secret.key;
proxy_pass http://127.0.0.1:8080;
}
The keyring is an ordinary exported GnuPG public keyring: adding a colleague means adding their public key to it.
pgp_revocation_list names a file of revoked primary-key fingerprints, re-read when it changes, so revoking a key and every live session it holds needs no reload.
Challenge and session lifetimes default to 120 seconds and one hour.
What this does not solve
Serve it over HTTPS. The session cookie is a bearer token like any other; the module sets HttpOnly, SameSite and Secure, and can bind the token to the client IP and User-Agent, but none of that substitutes for TLS.
It does not give you SSO, group membership, or an audit trail across systems. It answers one question does this client hold a key you trust and issues a session for it. And your users need PGP keys, and need to be comfortable clear-signing a block of text.
Where to get it
- Source, documentation and threat model: https://github.com/didi75345/ngx_http_pgp_auth_module
- Debian packages and the archive key fingerprint: https://didi75345.github.io/ngx_http_pgp_auth_module
- Current release: v1.0.0, for Debian Bookworm and Trixie, amd64. MIT licence.
