Mfa Firewall Knocker
MFA Firewall Knocker
Open a firewall port for your IP — but only after real multi-factor authentication.
Exposing SSH, RDP, or any admin port to the internet means exposing it to everyone. Closing it means you can't reach it either. This is the middle path: the port stays closed by default, and a user who proves who they are with a passkey gets a firewall rule opened for their source IP only, which expires automatically.
It's port knocking, except the knock is WebAuthn instead of a magic packet sequence.
The page tells you which IP it is about to open, before you authenticate.
#Why this exists
WireGuard and conventional file-backed SSH credentials authenticate a key. They do not answer the question that actually matters:
Is the authorized user the one using this key, right now?
Possession is the whole test for those files. A copied WireGuard profile or conventional
id_ed25519 passes from anywhere until it is revoked, and copying it does not alert the protocol.
OpenSSH FIDO credentials (sk-ecdsa-* and sk-ssh-ed25519-*) are an existing alternative for
SSH: their local key handles require the security key and can require touch or PIN verification.
This project answers that question before the protocol ever sees a packet. The port is closed by default; it opens only for the source IP of someone who just proved who they are with a phishing-resistant credential — a passkey, requiring their enrolled device and their biometric or PIN. A stolen key alone can no longer reach the service that would accept it. The grant expires on its own, and every one is logged: who, from where, when.
It sits in front of SSH, WireGuard, RDP, or anything else guarded by a port, without replacing them or asking you to migrate anything. WireGuard was the original reason this exists — if that's your use case, WIREGUARD.md covers the parts specific to it: config, what happens to a session in progress when a grant expires, and how roaming interacts with a per-IP gate.
#It is often a requirement, not just a good idea
Multi-factor authentication for remote network access is a control that turns up wherever someone is checking: cyber-insurance questionnaires, NIST SP 800-171 (3.5.3) and therefore CMMC, PCI-DSS, and most SOC 2 programs.
A WireGuard profile or conventional file-backed SSH key is one possession factor. OpenSSH FIDO keys can already add hardware presence and user verification for SSH. Putting this gate in front of a port adds an independent WebAuthn ceremony for WireGuard, conventional SSH keys, and other services, verified at the moment of access rather than only at enrollment.
Whether that satisfies your particular auditor, carrier or program is a question only they can answer, and this project makes no compliance claim on your behalf. But if you have been asked whether remote access to your network sits behind MFA, and the answer was "not really", this is a direct way to change that without replacing the VPN you already run.
#How this differs from Tailscale and friends
Products like Tailscale, Twingate and Cloudflare Access address the same underlying problem, and they are mature, well-built things. This is not a drop-in replacement for any of them — it is a different way of binding a key to a person, with genuinely different trade-offs.
They bind the key at enrolment. The device generates its own key, the key never leaves it, and SSO ties it to a real identity, so the credential is non-transferable by construction. Then they build a network on top: mesh routing, NAT traversal, DNS, ACLs, exit nodes. The binding holds until the key expires or you revoke it centrally.
This binds the key at time of use. It never touches your SSH or WireGuard keys at all. It makes the port unreachable until a human proves who they are — right now, with a phishing-resistant credential — and then opens it narrowly and briefly.
| Overlay products | This | |
|---|---|---|
| Binds key to human | at enrolment | at each use |
| Builds a network | yes — mesh, NAT traversal, DNS, routing | no |
| Client agent | required | none; any browser with a passkey |
| Third party in the trust path | yes | none |
| Existing keys and topology | replaced or absorbed | untouched |
| Re-proof of the human | at enrolment, then on key expiry | every session |
| Identity/ACL platform | SSO, SCIM, device posture, ACL language | none |
The deeper difference is philosophical, not just featural. This is deliberately small — closer
in spirit to WireGuard than to a platform. The entire mechanism fits in a sentence: prove you are
a human with a passkey, a firewall rule appears for your IP, it expires. You can verify the whole
thing with iptables -S. There is no control plane, no coordination server, no agent, no overlay;
the state you have to reason about is a firewall rule and a user file. One person can read the
source end to end in an afternoon.
That is the same bet WireGuard made — be small enough to audit rather than large enough to cover everything. It is why TOTP is compiled out rather than config-disabled, and why the built-in ACME client was deleted rather than repaired: a capability that isn't there cannot be misconfigured, and cannot hide a bug.
Tailscale is an enterprise system, and that is not a criticism. Fleet management, SSO/SCIM, device posture, an ACL language and audit tooling are what organisations genuinely need at scale, and you cannot deliver them in something a single person reads in an afternoon. The moving parts are the point.
The flip side is just as real: small means it does less. If you need those capabilities, "you can read the whole thing" is not a substitute for having them.
Pick accordingly. If you need a network — reaching things behind NAT, routing subnets, naming hosts — those products build one and this does not. If what you have already works and you only want a human gate in front of it, this adds one without a third party, an agent, or a migration.
Its own weaknesses are worth stating plainly: per-IP gating degrades behind CGNAT and most corporate VPNs, where several users can share one address or a user's address can change between requests. iCloud Private Relay is a related but distinct case, not the same failure mode. It proxies only standard web traffic (HTTP/HTTPS) through its relay and does not proxy WireGuard's UDP port or other non-web protocols, so a mismatch only arises if authenticating to MFAWeb happens over the relay while the protected connection does not. Reached without Private Relay in the path — disabled for that site, or the network itself isn't relayed — both legs present the same address and gating works as expected. Once a rule is open it is open to that IP for the window, with no per-connection authorisation; and it does no encryption of its own, relying entirely on the protocol behind the port.
#How it works
Browser ──HTTPS──▶ MFAWeb ──named pipe / unix socket──▶ MFAService
(unprivileged) (privileged)
passkey (WebAuthn) writes firewall rules
read-only to DB owns users.dat
- You hit MFAWeb from the machine you want access from. It shows you the IP it sees.
- You authenticate with a FIDO2 passkey (or a TOTP code, in a build made with
-p:AllowTotp=true). - MFAWeb hands the request to MFAService over local IPC. It never touches the firewall itself.
- MFAService independently re-validates the request and opens a rule scoped to that single IP and the ports you allowed.
- A sweeper removes the rule once it expires (default: 1 hour).
The split matters. MFAWeb is the part exposed to the internet, so it runs with only the privileges it needs to serve HTTPS — it cannot write to the user database and cannot issue a firewall command directly. MFAService is never exposed to the network and re-checks every policy decision rather than trusting its caller.
#Features
- Passkey-only by default, with user verification required as built. WebAuthn/FIDO2 on a platform authenticator, and every registration and every login demands a biometric or device PIN — possession of the device alone is never enough. TOTP is not compiled in unless you ask for it at build time. See Passkey requirements — this is stricter than most WebAuthn deployments and will reject a YubiKey
- Per-IP, auto-expiring firewall rules — nothing is left open
- Public-IP-only enforcement — requests from RFC-1918, CGNAT, loopback, and link-local ranges are rejected, on both sides of the privilege boundary
- No account lockout by design — usernames are email addresses and therefore guessable, so lockout would be a trivial DoS. Throttling is per-IP; failed logins are detected and alerted on instead
- In the default passkey-only build, enrolled passkeys add no private key to the user store.
The file holds public keys and a user list. Alongside a one-time enrollment password —
a bootstrap credential checked exactly once, at registration, never used to log in, and
regenerated fresh by every
addorreprovision— enrollment and reprovisioning also mint a short-lived registration token and readiness state. Both read and write access need protection: a reader can disclose hashes or race an active enrollment, while a writer can enroll their own credential. MFAService and MFAAdmin — the only two writers — serialize against each other through a cross-process mutex; the internet-facing MFAWeb never writes the store directly, and reads it without that same serialization - Building with
-p:AllowTotp=truechanges that, and it is the main reason the flag is not the default. TOTP verification isHMAC-SHA1(secret, timestep), so the server must keep each shared secret in recoverable form — it cannot be hashed, because a hash cannot generate codes. This adds immediately usable shared authenticator secrets to the account, hash, and enrollment data already present, so with TOTP enabled read access has a much larger blast radius. The store is DPAPI-encrypted on Windows and plain JSON on Linux; weigh that especially carefully on Linux. See SECURITY.md for the full comparison - TLS cert resilience on both platforms — Windows selects from the certificate store by CN and SAN, newest valid one wins; Linux re-reads the PEM every minute and hot-swaps when the thumbprint changes. Either way a renewal is picked up without a restart (verified against a real forced renewal), a failed read keeps the last good certificate rather than dropping TLS, and an expired cert degrades to a warning banner instead of a startup crash
- Cert expiry email alerts from the always-on privileged service, so the warning still arrives in the one failure mode that matters: when no usable cert exists and the web app can't serve HTTPS
- Ignores
X-Forwarded-For— the client IP always comes from the TCP connection, so it can't be spoofed with a forged header
#Components
| Component | Role | Runs as |
|---|---|---|
| MFAWeb | Internet-facing ASP.NET Core app. Authenticates users, then asks MFAService to open the firewall. | gMSA (Windows) / dedicated user (Linux) |
| MFAService | Privileged background service. Owns the user database and issues firewall commands. Never exposed to the internet. | LocalSystem (Windows) / root (Linux) |
| MFAAdmin | Command-line tool for provisioning and managing users. | Administrator (Windows) / root (Linux) |
#Passkey requirements
The WebAuthn configuration is deliberately strict. As written it requires a platform authenticator with user verification — the built-in kind, unlocked by biometric or device PIN:
| Setting | Value | Consequence |
|---|---|---|
AuthenticatorAttachment |
Platform |
Only built-in authenticators: Windows Hello, Touch ID / Face ID, Android biometric. The browser refuses a roaming security key — YubiKey, Titan, SoloKeys — at registration. This is client-enforced, not server-verified: with attestationPreference: None the server has no attestation statement to check attachment type against, so it's trusting the browser's own ceremony, not independently confirming what registered. The registrant only weakens their own account by working around it, so the practical risk is low — but it means this line is a request the client honors, not a guarantee the server can make on its own. |
UserVerification |
Required |
Biometric or device PIN on every registration and every login. A tap-only key is not enough. Enforced server-side on each assertion. |
RequireResidentKey |
false |
Non-discoverable credential: the user types their email address first. No usernameless "just tap" login. |
attestationPreference |
None |
The server does not verify authenticator make or model. |
Set in MFAWeb/Program.cs (registration options and assertion options).
This policy requires a platform authenticator and user verification, but it does not establish that a credential is hardware-bound or non-exportable. Passkey portability, backup, and account recovery follow the selected platform or credential provider.
This works out of the box on iOS/iPadOS, macOS (Touch ID / Face ID), Windows Hello, and Android.
UserVerification: Required in practice — the OS demands a biometric or PIN on
every login, not just at enrolment. Touch ID on macOS shown here.
Practical consequences to plan for:
- Whether a passkey survives losing the device depends on the platform. Apple passkeys
sync through iCloud Keychain and Google's sync through Google Password Manager, so the
credential is available on the user's other devices in the same ecosystem. Windows Hello
passkeys have historically been device-bound (newer Windows 11 builds add sync via Microsoft
account or a third-party provider). Plan recovery around the pessimistic case: an admin
runs
MFAAdmin reprovision <email>. - Machines without a platform authenticator cannot register a passkey at all. An older
desktop with no Hello-capable hardware has no way in unless you rebuild all three components
with
-p:AllowTotp=trueto include TOTP. - If you want security-key support, change
AuthenticatorAttachmenttoCrossPlatform, or drop the property entirely to allow both. KeepUserVerification = Requiredif you do — a PIN-less key would weaken the factor to mere possession.
#Passkey-only builds (the default)
Two independent reasons.
Codes are phishable and time-sensitive. A captured, unused code stays valid for roughly 90 seconds. This implementation persists the accepted time-step and rejects a second use, but an attacker who submits a captured code first can still win the race. An account is only as strong as its weakest enrolled method, so leaving TOTP available gives up much of the passkey's protection.
The server must store the secret in the clear. This one is inherent to the protocol, not to
this implementation. Verifying a code means computing HMAC-SHA1(secret, timestep) and comparing,
so the server needs the shared secret in recoverable form. You cannot hash it like a password —
a hash cannot generate codes. Encrypting the store helps against theft of the file alone, but the
service has to decrypt it to work, so the material stays recoverable. Enabling TOTP therefore
turns a database breach that exposes account, hash, and enrollment data but no private WebAuthn
key into one that also yields valid second factors for every TOTP-enrolled user at once.
See SECURITY.md for the full comparison.
TOTP is therefore a compile-time decision, not a setting. By default it is not built at all:
- There is no
/authroute and no TOTP enrollment route — they return404, because no handler exists rather than one that declines. - The login page emits no TOTP form, and
MFAServicedoesn't even carry theBURN_TOTP_TOKENIPC verb. MFAAdmin addandreprovisionmint no TOTP secret, and the provisioning email omits the authenticator-app link.users.datholds no recoverable shared secret — only passkey public keys, account data, BCrypt hashes, and any short-lived enrollment state.
There is no configuration key to get this wrong, nothing to leave in the weaker state by mistake, and no second code path for a reviewer to audit.
If you need TOTP — typically because some users are on machines with no platform authenticator — build all three components with the flag:
dotnet build MFA.slnx -c Release -p:AllowTotp=true
Use the same flag for every component. They are deployed together anyway, since they share the
users.dat schema. A mismatch is not dangerous — one direction leaves unused secrets in the
database, the other offers a login that always fails — but it is not useful either.
Moving an existing deployment to a passkey-only build? Rebuilding stops new secrets being minted but does not remove secrets already in the database. Run
MFAAdmin purge-totpto clear them, or you have a passkey-only deployment still sitting on live secrets. That command deliberately skips accounts with no passkey enrolled — clearing those would lock the user out entirely — and lists them so you can reprovision them first.
#Requirements
- .NET 10 (runtime, or publish self-contained)
- Windows: Windows Server 2019+, PowerShell 5.1+ with the
NetSecuritymodule, and an Active Directory domain if you want to run MFAWeb under a gMSA - Linux: systemd, and
iptables(see the note below) - An SMTP relay with STARTTLS, for user provisioning emails and alerts. Plaintext SMTP is accepted only for an explicit loopback relay.
- A TLS certificate for MFAWeb. MFAWeb is not an ACME client; obtain it with certbot (Linux)
or win-acme (Windows). certbot
--standaloneneeds port 80 reachable during issuance only.
Linux firewall backends: the built-in Linux path uses
iptables. If your distro usesnftables,ufw, orfirewalld, adapt the two clearly-marked sections inOpenFirewallPortandSweepExpiredRulesinMFAService/Program.cs. See the Linux Firewall Commands section of INSTALL.md.
#Download
Prebuilt, self-contained archives are on the
releases page — no .NET runtime
install needed. Each archive holds all three components, which share the users.dat schema and
must be deployed together. Both are the default passkey-only build; TOTP requires building
from source with -p:AllowTotp=true.
| Archive | Notes |
|---|---|
mfa-firewall-knocker-<version>-win-x64.zip |
Code-signed (Azure Trusted Signing) and timestamped |
mfa-firewall-knocker-<version>-linux-x64.tar.gz |
Unsigned — there is no OS-level ELF signature to check |
Verify before installing. On Windows the signature is the stronger check:
signtool verify /pa MFAWeb.exe # or right-click -> Properties -> Digital Signatures
On Linux, use the checksums published alongside the archives:
sha256sum -c SHA256SUMS.txt --ignore-missing
Then follow INSTALL.md, which covers gMSA setup, systemd units, certbot, file permissions, and the shared IPC group.
#Quick start (from source)
git clone https://github.com/PNWSoft/mfa-firewall-knocker.git cd mfa-firewall-knocker # Configure each component from its template cp MFAWeb/appsettings.example.json MFAWeb/appsettings.json cp MFAService/appsettings.example.json MFAService/appsettings.json cp MFAAdmin/appsettings.example.json MFAAdmin/appsettings.json # ...then edit each one. At minimum: AppUrl, AllowedDomains, HttpsCert, Smtp, and a # DpapiEntropy that is IDENTICAL in all three (startup refuses the placeholder value). # Build all three dotnet build MFA.slnx -c Release # Or publish for deployment (Windows, self-contained) dotnet publish MFAWeb/MFAWeb.csproj -c Release -r win-x64 --self-contained dotnet publish MFAService/MFAService.csproj -c Release -r win-x64 --self-contained dotnet publish MFAAdmin/MFAAdmin.csproj -c Release -r win-x64 --self-contained
Then add your first user, running MFAAdmin elevated (Administrator on Windows, root on Linux):
MFAAdmin add you@your-domain.com
They get an email with a passkey setup link, valid for 60 minutes. In a build made with
-p:AllowTotp=true it also contains a TOTP setup link.
INSTALL.md is the real guide — gMSA creation, service installation, systemd units, socket permissions, TLS options, and file permissions are all covered there. Read it before deploying.
#Configuration
Every component reads its own appsettings.json, which is gitignored because it holds secrets.
Copy the matching appsettings.example.json and fill it in. The full key reference is in
INSTALL.md.
A few that are easy to get wrong:
DpapiEntropy— a unique random string mixed into the DPAPI key derivation. It must be identical across all three components and must not be left at the placeholder value.HttpsCert:Subject/Store/Location— must match between MFAWeb and MFAService, or the cert monitor will watch a different certificate than the one being served.AllowedDomains— restricts which email domains can be provisioned.BouncerConfig:AllowedPorts— the only ports MFAService will ever open, e.g.["22/TCP"].LogoUrl— leave empty to use the bundled knocker logo, or point it at your own image.
Do not put a reverse proxy in front of MFAWeb. It deliberately ignores
X-Forwarded-ForandX-Real-IPand reads the client IP from the TCP connection. Behind a proxy, every request would appear to come from the proxy — collapsing rate limiting and opening the firewall for the wrong address. Bind it directly to the public interface.
#Do not make this your only way in
No single system should be the sole path to a network — including this one. Treat the gate as one access route among several, never the only one. A redundant design isn't paranoia here; it's the difference between an incident and an outage you cannot remotely fix.
The failure mode is specific and worth understanding, because it is not obvious:
An expired TLS certificate locks everyone out completely. WebAuthn requires a secure context, so a browser will refuse to run the passkey ceremony on an invalid certificate — and it won't let the user click through. In a passkey-only build there is no second factor to fall back to. Nobody authenticates, so nobody opens a firewall rule. If SSH to that host is itself gated, you have no way in to fix the certificate that is causing the problem.
The same shape applies to any single dependency: the service crashing, a bad config push, the host rebooting into a broken state, a DNS or upstream network failure, or the gate's own database becoming unreadable.
Practical redundancy, roughly in order of value:
- Run more than one gate, on independent hosts, each with its own certificate, its own DNS name, and its own firewall. Independence is the point — two gates sharing a host, a cert, or an upstream link fail together.
- Keep at least two keys that can open the gate — ideally in two different pockets. You
wouldn't cut a single key to a house. Best is two administrators, each enrolled on their
own device: that survives a lost phone and a person being unreachable. A single admin
should still hold two keys of their own — and since an account holds exactly one passkey
as built (deliberately; see the security invariants), that means either a passkey that syncs
across their devices (iCloud Keychain, Google Password Manager) or a second account enrolled
on a different device (
alice@…plusalice.backup@…). Windows Hello passkeys are historically device-bound, so a Windows-only admin with one account has exactly one key.MFAAdmin reprovisionreplaces a lost key — but it runs elevated on the host, and reaching the host may itself depend on the gate. That circularity is exactly what the second key is for. - Keep an out-of-band console — IPMI/iDRAC/iLO, a cloud provider's serial console, or a hypervisor console — that does not depend on the gate or on SSH.
- Keep a break-glass path that is normally disabled and separately monitored: a bastion reachable from one fixed address, or a rule you can enable from the console.
- Monitor the certificate from outside, not only from the box. MFAService emails on approaching expiry on both platforms, but that alert travels over the same infrastructure that may be failing.
- Test recovery before you need it. Deliberately break the gate on a maintenance window and confirm you can still get in.
MFAWeb reloads its certificate without a restart on both platforms, and MFAService warns by email before expiry, precisely because this failure mode is severe. Those reduce the likelihood. They do not remove the need for a second way in.
#Revoking access does not end active sessions
Rules disappear a few ways: they expire, an admin runs MFAAdmin reset, or (on Windows)
MFAAdmin delete removes a deleted user's own rules as part of removing the account. All of
them close the firewall to new connections. None reliably ends a session that is already
connected.
Whether an established session survives depends on your platform, your firewall, and what is listening behind the port — and a client reaching that port through some other rule (a permanently open port, a trusted interface, a separate allow) is unaffected either way.
Prepare a termination procedure before you need it, and treat it as your responsibility. Only you can decide what it should do, because the right action is environment-specific: dropping connection-tracking state, restarting or reconfiguring the service behind the port, killing the user's processes or login sessions, revoking a credential at the application layer, or some combination. What ends a WireGuard tunnel is not what ends an SSH session, an RDP session, or a database connection.
This project deliberately does not attempt it. Guessing wrong while running as root or LocalSystem is worse than doing nothing, and a tool that claimed to cut sessions but quietly didn't would be the most dangerous option of the three.
So: write the script, test it against a real session, and keep it somewhere you can reach during an incident — ideally the same out-of-band path you keep for lockout recovery. Then, when you revoke, you know whether you have closed the door or actually removed the person.
#Security notes
- The user database stores TOTP secrets in recoverable form when TOTP is compiled in. In the default build, passkey credentials are public keys, but usernames, the current one-time enrollment password hash, and active enrollment tokens still make the database confidential. Protect it with the filesystem permissions documented in INSTALL.md.
- Firewall rules expire after
ExpirationHours; the sweeper runs every 5 minutes. - Passkey registration always requires proof of the enrollment password or a post-login token —
in the default passkey-only build that password is never anything else: it's checked once, at
registration, then burned on success or once its 60-minute window expires unused, whichever
comes first. That changes once an account confirms TOTP — from then on the same password is
also that account's persistent login credential, checked on every
/authrequest, and stops being burned by either path. The registration check itself doesn't change; what the field means afterward does. That check is a deliberate invariant rather than an incidental one — treat any change to that path with care. - This software is provided as-is under the MIT license, with no warranty. It manipulates firewall rules on a privileged host. Review the code and test in a non-production environment first.
Please report security issues privately — see SECURITY.md, which also lists what is explicitly out of scope (no account lockout, username enumeration, and the trust boundary around a compromised MFAWeb are deliberate design decisions) and the current known issues.
#Contributing
Issues and pull requests are welcome. How it works is the quickest way to get oriented, and SECURITY.md records what is deliberately out of scope, which is worth reading before proposing a change that adds one of those things back.
#License
Code: MIT — see LICENSE.
Artwork: the door knocker used for the application icon and login logo is CC0 1.0 (public domain dedication) — no attribution required, commercial use permitted. Source and regeneration notes are in assets/. Deliberately chosen so the whole repository is reusable without a licence mismatch between the code and its graphics.