CoursesLinux hardeningKeys, MFA & bastions

Keys, MFA & bastions

Strong auth and a single front door.

Intermediate12 min · lesson 2 of 16

A password is a secret you say out loud. Say it at the right door and you're in. The trouble is that saying it is the whole trick: anyone who overhears it, phishes it, or guesses it can say the same word and walk in behind you. An SSH key (Secure Shell, the encrypted protocol you use to log into remote machines) works more like a physical key that never leaves your pocket. You prove you hold it without ever handing it over. This lesson is about making that proof strong, adding a second lock to the one door that's left, and funneling every login through a single guarded entrance you can watch.

How A Key Proves You're You

A key pair is two matching files. The private key is the physical key in your pocket. The public key is the shape of the lock that only that key opens. You hand out the lock shape freely and keep the key. When you connect, the server sends your client a random challenge. Your client signs that challenge with the private key and sends back the signature. The server checks the signature against the public key it already holds. If it matches, you're in, and the private key itself never crossed the wire. Overhearing the whole conversation gives an attacker nothing they can replay.

Generate a modern pair. The ed25519 algorithm (a current public-key signature scheme) is the right default: the keys are tiny, signing is fast, and the security is strong. Give it a passphrase. The passphrase encrypts the private key file on disk, so a key file copied off your laptop is a locked box, not a working key.

~/secopslog — bash
$ ssh-keygen -t ed25519 -C "deploy@laptop"
Generating public/private ed25519 key pair. Enter file in which to save the key (/home/deploy/.ssh/id_ed25519): Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /home/deploy/.ssh/id_ed25519 Your public key has been saved in /home/deploy/.ssh/id_ed25519.pub The key fingerprint is: SHA256:5r8K2u1oQ9wN3xY7bT0cVfHj4pLmZ6dE8sA1gR2nW0k deploy@laptop The key's randomart image is: +--[ED25519 256]--+ | .o+=B*E | | o.oB=+ | | . +.*+o | | o = +. | | S o * | | . = o | | o o . | | . . . | | . | +----[SHA256]-----+

That produced two files. The private key lives at ~/.ssh/id_ed25519 and never leaves this machine. The public key, the .pub file, is the one you copy to servers. The ssh-copy-id helper appends it to the remote account's ~/.ssh/authorized_keys, which is the list of public keys allowed to log in as that user.

~/secopslog — bash
$ ssh-copy-id deploy@server ssh deploy@server 'ls -l ~/.ssh/authorized_keys'
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/deploy/.ssh/id_ed25519.pub" /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys deploy@server's password: Number of key(s) added: 1 Now try logging into the machine, with: "ssh 'deploy@server'" and check to make sure that only the key(s) you wanted were added. -rw------- 1 deploy deploy 98 Jul 17 09:02 /home/deploy/.ssh/authorized_keys

Notice the permissions: 600, owner read and write only. This matters. The SSH server refuses to trust an authorized_keys file (or a .ssh directory) that other users can write to, because a writable list of allowed keys is a writable list of allowed people. That refusal is the StrictModes check, and it's on by default. If key login ever fails for no obvious reason, wrong permissions are the first thing to check.

Two habits keep the private key private. Load it into ssh-agent once at the start of your session (the agent holds the decrypted key in memory, so you type the passphrase a single time and the key never gets scattered into scripts or config files). And the day someone leaves the team, delete their public-key line from every authorized_keys. A key you forget to remove is a door you forgot to lock. A private key never belongs in a git repository or on a server; the moment it lands in either place, treat it as leaked and generate a fresh one.

Turn Off What Attackers Actually Hammer

Here's the part people forget. Installing a key does not remove the password door. As long as password login is switched on, every server on port 22 still accepts guesses, and the public internet is full of bots doing nothing but guessing, all day, forever. You close that door in the server's config (sshd is the background process, or daemon, that answers SSH connections).

/etc/ssh/sshd_config.d/50-harden.conf
# only keys get in; no passwords, no root logins
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no

Modern SSH reads drop-in files from /etc/ssh/sshd_config.d/, so you add a small file of your own instead of editing the big shared one. Test the config before you apply it, then reload. Running sshd -t parses the config and prints nothing if it is valid, which is the Unix way of saying 'fine.' A reload re-reads the config without dropping the connections that are already open.

~/secopslog — bash
$ sudo sshd -t && echo OK sudo systemctl reload ssh
OK

Now look at what the box was taking before. Even with passwords off, the bots keep knocking, and every knock is a log line. This is the defender's view: a steady drizzle of invalid usernames from addresses all over the world.

~/secopslog — bash
$ sudo journalctl -u ssh --since "1 hour ago" -g "Invalid user" | tail -6
Jul 17 09:14:02 web01 sshd[24518]: Invalid user admin from 203.0.113.44 port 51234 Jul 17 09:14:05 web01 sshd[24520]: Invalid user postgres from 198.51.100.7 port 40122 Jul 17 09:14:11 web01 sshd[24525]: Invalid user oracle from 192.0.2.19 port 33880 Jul 17 09:15:47 web01 sshd[24540]: Invalid user test from 203.0.113.44 port 52001 Jul 17 09:16:02 web01 sshd[24559]: Invalid user ubuntu from 192.0.2.55 port 60122 Jul 17 09:16:20 web01 sshd[24571]: Invalid user git from 198.51.100.7 port 41988

Each 'Invalid user' line is a bot trying a common account name, getting nowhere, and moving on. With password login off and no such user, there is nothing to brute force, so the noise is harmless. What you actually watch for is the shape that isn't a bot: repeated tries against a real username, from one address, over a long stretch. That is someone who did their homework, and it deserves a closer look and maybe a firewall block.

Keep one session open while you change sshd
Every change to the SSH config is a chance to lock yourself out. Reload the service (which leaves current connections alive) rather than restart, keep your existing session open, and open a second, fresh session to confirm you can still get in before you close the first. If the new session fails, you fix it from the one that's still connected. Close both while the config is broken and your next move is a drive to the data center or a cloud console's serial port.

A Second Lock On The Only Door Left

With passwords gone, the key is the only way in, which makes the key worth protecting twice. It works like a bank card. The card in your wallet plus the PIN in your head, and a thief needs both to take your money. MFA (multi-factor authentication) is that same idea applied to a login. It asks for two different kinds of proof: something you have, like the key or a hardware token you hold, plus something you know, like a PIN or a short rotating code. One of the two on its own gets nobody in. There are two common ways to add it to SSH.

The first is a hardware security key, a small token like a YubiKey that speaks the FIDO2 and U2F standards (open standards for hardware-backed login). You generate a special key type, ed25519-sk (the 'sk' means security key), where the real secret lives inside the token and cannot be copied out. Logging in then requires the physical token to be plugged in and tapped. A key file stolen off your laptop is now useless, because the actual secret never sat in a file to begin with.

~/secopslog — bash
$ ssh-keygen -t ed25519-sk -O resident -O verify-required -C "deploy@yubikey"
Generating public/private ed25519-sk key pair. You may need to touch your authenticator to authorize key generation. Enter PIN for authenticator: You may need to touch your authenticator again to authorize key generation. Enter file in which to save the key (/home/deploy/.ssh/id_ed25519_sk): Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /home/deploy/.ssh/id_ed25519_sk Your public key has been saved in /home/deploy/.ssh/id_ed25519_sk.pub The key fingerprint is: SHA256:9Qz7mTn2kR4vP1sX6yB0cLdH8jF3aW5eU7gN0oM2rK4 deploy@yubikey

The verify-required flag adds a PIN check on top of the tap, so a stolen token still needs something you know. The second approach is TOTP (time-based one-time password, the rotating six-digit code an app shows you). Linux wires it in through PAM (Pluggable Authentication Modules, the framework that lets you stack extra login checks). You install the module (apt install libpam-google-authenticator), run google-authenticator to enroll a user and scan the QR code, add one line to the PAM stack, and then tell the SSH server to demand both a key and a code.

/etc/pam.d/sshd
# ask for the rotating code as part of the SSH login
auth required pam_google_authenticator.so
/etc/ssh/sshd_config.d/60-mfa.conf
# a valid key AND a keyboard-interactive step (the TOTP code via PAM)
AuthenticationMethods publickey,keyboard-interactive
KbdInteractiveAuthentication yes
UsePAM yes
Commas mean AND, spaces mean OR
In the AuthenticationMethods line, a comma-separated list is a sequence that must all succeed. So publickey,keyboard-interactive means key AND code. Write them with a space instead, publickey keyboard-interactive, and you have listed two alternatives, where either one alone gets someone in. That quietly defeats the whole point of a second factor. After any change here, prove it from a second session: you should be asked for the code after the key, every single time.

One Front Door

Exposing SSH on every server to the internet is like giving every room in an office its own street entrance. That is a lot of doors to lock, watch, and patch. A bastion (also called a jump host) is the single staffed reception desk: the only machine reachable from outside, hardened and heavily logged, and the only thing your internal servers will accept SSH from. The attack surface drops from 'every host on port 22' to 'one gateway you watch closely.'

You do not want to log into the bastion and then type a second ssh command from there, because that puts your key, or an agent connection, on the machine most exposed to attack. ProxyJump avoids that. It opens an encrypted tunnel through the bastion and runs the real login end to end between your laptop and the internal host. The bastion passes bytes through and never sees the key you use for that internal host.

~/.ssh/config
Host bastion
HostName bastion.acme.internal
User deploy
IdentityFile ~/.ssh/id_ed25519
Host app-*
HostName %h.acme.internal
User deploy
ProxyJump bastion # ssh app-01 transparently hops through the bastion

Now ssh app-01 quietly hops through the bastion and lands on the internal host. One command, and you never typed the bastion's name.

~/secopslog — bash
$ ssh app-01 hostname
app-01

On the internal hosts, close the loop two ways. Point the firewall so port 22 answers only the bastion's address, and pin the key itself to that source with a from= restriction inside authorized_keys. The from= clause turns a stolen public-key line into something that only works when the connection actually originates from the bastion.

~/.ssh/authorized_keys
from="10.0.5.8" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8s0m2pQ7v9kZ2xR4tW6yB1cLmN3dF5gH7jK8sP0aQe deploy@laptop
~/secopslog — bash
$ sudo ufw status
Status: active To Action From -- ------ ---- 22/tcp ALLOW 10.0.5.8

Because every session now passes through one machine, the bastion is also your ledger. A single host's logs hold who connected, when, and where they went next. That is the operational payoff hiding inside the security one: a single place to audit access instead of scraping fifty scattered log files.

One SSH login through a bastion
1Your laptop
private key held in ssh-agent, unlocked by your passphrase
2Bastion (front door)
the only host reachable from outside; checks the public key, prompts for the second factor, logs the session
3ProxyJump tunnel
encrypted hop end to end; no key ever stored on the bastion
4Internal host
firewall and from= accept port 22 only from the bastion's IP
Quick check
01You want a login to require a key AND a TOTP code, and you write: AuthenticationMethods publickey keyboard-interactive (with a space between them). What actually happens?
Incorrect — A space separates alternatives, not a required sequence; only a comma chains them.
Correct — Space-separated entries are alternative sets (OR), so the key by itself satisfies one of them.
Incorrect — The line is valid syntax; it only means something weaker than you intended.
Incorrect — Both remain accepted paths; neither is ignored, and either alone suffices.
02You generate an ed25519-sk key with ssh-keygen. What makes a copy of the id_ed25519_sk file, stolen off your laptop, useless to an attacker?
Incorrect — a passphrase can protect an ordinary key file, but that is not what makes the -sk type special.
Incorrect — ssh-copy-id installs the public half either way; the protection is about where the secret lives, not what got copied.
Correct — the 'sk' (security key) design keeps the private secret on the token, so a stolen file alone cannot authenticate.
Incorrect — SSH keys do not auto-rotate per login; the token, not expiry, is what defeats the theft.
03Key login to a server suddenly stops working, with no config change you can find. You notice someone ran 'chmod 0777' on that user's home directory. What is the most likely cause?
Correct — a world-writable home means a writable path to ~/.ssh, which trips StrictModes and SSH declines the key.
Incorrect — chmod does not delete the key; StrictModes simply stops trusting it.
Incorrect — 0777 is more permissive, not more restrictive; the problem is that it is too open, not too tight.
Incorrect — enabling passwords would not disable a valid key; the writable path is what breaks it.

Before you call it done, prove the password door is actually shut. Force SSH to offer only a password and watch it bounce.

~/secopslog — bash
$ ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@server
deploy@server: Permission denied (publickey).

That 'Permission denied (publickey)' is the line you want to see. It means the server never even entertained the password path, and the only way in is the key you are holding.

Try this

Work through “One Front Door” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: keep one session open while you change sshd. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related