Skip to content

Split View: SSH 운영 완전 가이드: 키 관리부터 잠금 사고 없는 서버 하드닝까지

|

SSH 운영 완전 가이드: 키 관리부터 잠금 사고 없는 서버 하드닝까지

들어가며

SSH는 배우기는 쉽고 운영하기는 어렵습니다. 접속만 하면 되던 시절에는 ssh user@host로 충분했지만, 서버가 수십 대가 되고 감사 요건이 붙는 순간 질문이 달라집니다. 키는 누가 관리하는가, 퇴사자의 접근은 어떻게 끊는가, 배스천을 통과하는 경로는 어떻게 표준화하는가, 그리고 설정을 잘못 바꿔서 스스로 잠기면 어떻게 하는가.

이 블로그에는 SSH 프로토콜 심층 분석이 이미 있습니다. 그 글은 전송 계층, 인증 방식, 채널, 인증서, Terrapin 공격 같은 프로토콜 내부를 다룹니다. 이 글은 정반대 방향입니다. 프로토콜을 몰라도 되지만 서버 백 대를 안전하게 운영해야 하는 사람을 위한 설정과 절차가 주제입니다.

기준 버전은 OpenSSH 9.x 이상입니다. 옵션 가용성은 버전에 따라 다르므로, 이 글에서 언급한 지시어가 동작하지 않으면 서버에 설치된 버전을 먼저 확인하세요.

ssh -V
sshd -V

1. 키 만들기 — 어떤 종류를 고를 것인가

현재 권장은 두 가지입니다. Ed25519가 기본 선택이고, FIPS 준수 등 조직 정책이 요구할 때만 RSA 4096을 씁니다.

ssh-keygen -t ed25519 -C 'youngju@laptop-2026' -f ~/.ssh/id_ed25519
ssh-keygen -t rsa -b 4096 -C 'youngju@laptop-2026' -f ~/.ssh/id_rsa
ssh-keygen -t ecdsa-sk -f ~/.ssh/id_ecdsa_sk
ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk
  • -t는 키 종류, -b는 비트 수(RSA에만 의미 있음), -C는 주석, -f는 출력 파일입니다.
  • -sk 접미사가 붙은 종류는 FIDO2 하드웨어 보안 키를 요구합니다. 개인 키 자체가 하드웨어를 벗어나지 않으므로 유출 위험이 크게 줄어듭니다. OpenSSH 8.2 이상에서 지원합니다.

주석에 사람과 기기를 명시하는 습관이 중요합니다. authorized_keys에 20줄이 쌓였을 때 어느 줄이 누구 것인지 알 수 있는 유일한 단서입니다.

키에는 반드시 패스프레이즈를 겁니다. 자동화 때문에 어렵다면 에이전트를 씁니다.

ssh-keygen -p -f ~/.ssh/id_ed25519
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-add -l
ssh-add -D

ssh-add -D는 에이전트에 올린 키를 모두 제거합니다. 공용 워크스테이션을 떠날 때 습관으로 실행하세요.

지문 확인은 키 배포와 감사에 쓰입니다.

ssh-keygen -lf ~/.ssh/id_ed25519.pub
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

2. 키 배포와 회수 — authorized_keys 다루기

ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@10.0.3.14

ssh-copy-id는 편리하지만 서버 대수가 늘면 관리 방식으로는 부적합합니다. 구성 관리 도구(Ansible, Salt 등)나 SSH 인증서로 전환해야 합니다. 파일을 직접 관리한다면 권한이 정확해야 합니다. 권한이 느슨하면 sshd가 조용히 키를 거부하며, 이것이 "키를 넣었는데 비밀번호를 묻는다"의 가장 흔한 원인입니다.

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

홈 디렉터리 자체가 그룹 쓰기 가능이어도 거부됩니다.

chmod 755 ~

authorized_keys의 각 줄에는 제약을 걸 수 있습니다. 배포용 키처럼 용도가 정해진 키에 특히 유용합니다.

restrict,from="10.0.0.0/8",command="/usr/local/bin/deploy-only" ssh-ed25519 AAAAC3Nza... deploy@ci
  • restrict는 모든 기능을 끄는 안전한 기본값입니다. 이후 필요한 것만 다시 켭니다.
  • from=은 접속 출발지를 제한합니다.
  • command=은 클라이언트가 무엇을 요청하든 지정한 명령만 실행합니다.

회수는 파일에서 줄을 지우는 것으로 끝나지 않습니다. 이미 열려 있는 세션은 그대로 살아 있습니다. 완전한 회수는 다음 순서입니다.

sudo -u deploy sed -i '/deploy@ci/d' /home/deploy/.ssh/authorized_keys
who
sudo pkill -TERM -u deploy sshd

파괴적 명령 경고: 마지막 명령은 해당 사용자의 모든 SSH 세션을 끊습니다. 본인 세션이 포함될 수 있으니 사용자와 대상 계정을 반드시 확인하세요.


3. 클라이언트 설정 — ~/.ssh/config가 표준 문서다

접속 방법을 사람 기억이 아니라 파일에 적어 두면 팀 전체의 실수가 줄어듭니다.

Host bastion
  HostName bastion.example.com
  User youngju
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes
  ServerAliveInterval 30
  ServerAliveCountMax 3

Host prod-*
  User deploy
  ProxyJump bastion
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
  StrictHostKeyChecking yes

Host prod-web-01
  HostName 10.0.3.14

핵심 지시어의 의미는 다음과 같습니다.

  • ProxyJump(-J)는 배스천을 경유합니다. OpenSSH 7.3 이상에서 사용할 수 있으며, 이전에 쓰던 ProxyCommand 조합보다 훨씬 안전하고 간결합니다.
  • IdentitiesOnly yes지정한 키만 시도하게 합니다. 이것이 없으면 에이전트에 올라간 모든 키를 차례로 시도하다가 서버의 MaxAuthTries(기본 6)에 걸려 인증이 실패합니다. 키가 여러 개인 사람이 겪는 대표적인 문제입니다.
  • ServerAliveIntervalServerAliveCountMax는 유휴 연결이 NAT나 방화벽에 의해 조용히 끊기는 것을 막습니다.

명령줄로도 같은 일을 할 수 있습니다.

ssh -J bastion deploy@10.0.3.14
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_deploy deploy@10.0.3.14

연결 재사용은 반복 접속을 크게 빠르게 합니다.

Host *
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 10m

ControlPersist는 마지막 세션 종료 후에도 마스터 연결을 유지할 시간입니다. 다만 공유 워크스테이션에서는 다른 사용자가 소켓을 재사용할 위험이 있으므로 소켓 경로 권한을 확인하세요.


4. 호스트 키 검증 — 경고를 무시하지 않기

호스트 키 경고는 중간자 공격의 유일한 방어선입니다. 그런데 서버를 재설치할 때마다 뜨기 때문에 습관적으로 무시하게 됩니다. 그 습관이 위험합니다.

ssh-keygen -F 10.0.3.14
ssh-keygen -R 10.0.3.14
ssh-keyscan -t ed25519 10.0.3.14
  • -F는 known_hosts에서 항목을 찾고, -R은 제거합니다.
  • ssh-keyscan은 서버의 호스트 키를 가져옵니다. 가져온 값을 검증 없이 신뢰하면 의미가 없으므로, 서버 콘솔에서 지문을 확인해 대조해야 합니다.

규모가 커지면 SSH 인증서로 전환하는 것이 정답입니다. 호스트 키를 CA로 서명해 두면 클라이언트는 CA 하나만 신뢰하면 됩니다.

@cert-authority *.example.com ssh-ed25519 AAAAC3Nza...

사용자 키도 같은 방식으로 서명할 수 있습니다. 이때 authorized_keys를 서버마다 관리할 필요가 없어지고, 유효 기간을 짧게 주어 회수 문제를 구조적으로 해결할 수 있습니다. 인증서 발급 절차와 주요 옵션은 조직의 CA 구현에 따라 다르므로, ssh-keygen의 인증서 관련 옵션은 설치된 버전의 man 페이지에서 확인하세요.


5. sshd 하드닝 — 잠기지 않는 순서로

서버 설정을 바꿀 때 가장 중요한 것은 순서입니다. 잘못하면 자신을 잠급니다.

안전한 절차는 다음과 같습니다.

  1. 현재 세션을 유지한 채 새 터미널을 하나 더 열어 둡니다.
  2. 설정 파일을 수정합니다.
  3. sshd -t로 문법을 검사합니다.
  4. 서비스를 재적재합니다.
  5. 기존 세션을 닫지 말고 새 터미널에서 접속을 확인합니다.
  6. 확인이 끝난 뒤에만 기존 세션을 닫습니다.
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak-2026-08-15
sudo vi /etc/ssh/sshd_config
sudo sshd -t
sudo systemctl reload sshd

sshd -t는 문법만 검사합니다. 실제 적용 값을 확인하려면 확장 테스트 모드를 씁니다.

sudo sshd -T | sort | head -40
sudo sshd -T -C user=deploy,host=10.0.3.14,addr=10.0.3.14 | grep -i -E 'passwordauth|pubkey|permitroot'

-T는 유효 설정 전체를, -C는 특정 접속 조건에서의 Match 블록 적용 결과를 보여 줍니다. Match 블록을 쓴다면 이 명령으로 반드시 검증하세요. 조건이 예상과 다르게 걸리는 일이 잦습니다.

권장 설정 예시입니다. 각 지시어의 기본값은 man 페이지 기준입니다.

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowGroups sshusers
X11Forwarding no
PermitEmptyPasswords no
LogLevel VERBOSE

각 값의 의미를 짚습니다.

  • PermitRootLogin의 기본값은 prohibit-password입니다. 즉 기본 상태에서도 root의 비밀번호 로그인은 막혀 있고 키 로그인은 허용됩니다. 완전히 막으려면 no로 설정합니다.
  • PasswordAuthentication의 기본값은 yes입니다. 키 인증으로 전환했다면 반드시 명시적으로 no로 바꿔야 합니다.
  • KbdInteractiveAuthentication의 기본값도 yes입니다. 비밀번호를 막았다고 생각했는데 이 경로로 여전히 들어오는 경우가 있으니 함께 끄세요. 예전 이름인 ChallengeResponseAuthentication은 폐기된 별칭입니다.
  • MaxAuthTries의 기본값은 6이며, 실패가 이 값의 절반에 도달하면 그때부터 로그에 기록됩니다.
  • LoginGraceTime의 기본값은 120초입니다. 로그인하지 못한 연결을 오래 유지하지 않도록 줄이는 편이 좋습니다.
  • AllowGroups를 쓰면 지정한 그룹의 사용자만 로그인할 수 있습니다. DenyGroups가 먼저 처리되고 그다음 AllowGroups가 처리됩니다.
  • LogLevel VERBOSE는 인증에 사용된 키의 지문을 로그에 남깁니다. 감사 요건이 있는 환경에서는 사실상 필수입니다.

Match 블록으로 예외를 만듭니다.

Match Group sftponly
  ChrootDirectory /srv/sftp/%u
  ForceCommand internal-sftp
  AllowTcpForwarding no
  PermitTunnel no

Match는 조건이 맞으면 다음 Match나 파일 끝까지의 설정을 덮어씁니다. 같은 키워드가 여러 Match에서 만족되면 첫 번째 것만 적용됩니다. 순서가 결과를 바꾸므로 주의하세요.

배포판에 따라 설정이 분리되어 있습니다. 최근 Debian/Ubuntu와 RHEL 계열은 Include /etc/ssh/sshd_config.d/*.conf 형태로 조각 파일을 읽습니다. Include된 파일이 앞쪽에서 값을 먼저 정의하면 아래쪽 설정이 무시될 수 있으므로, 변경 전에 sshd -T로 실제 적용 값을 확인하는 습관이 중요합니다.


6. 포트 포워딩과 터널

ssh -L 15432:db.internal:5432 bastion
ssh -R 8080:localhost:3000 relay.example.com
ssh -D 1080 bastion
ssh -N -f -L 15432:db.internal:5432 bastion
  • -L로컬 포워딩입니다. 내 컴퓨터의 15432로 들어온 연결을 SSH 서버를 거쳐 db.internal:5432로 보냅니다. 내부망 DB에 붙을 때 씁니다.
  • -R원격 포워딩입니다. 서버의 포트를 내 컴퓨터로 끌어옵니다. 기본적으로 서버의 루프백에만 바인딩되며, 외부에 열려면 서버에서 GatewayPorts를 켜야 합니다.
  • -D동적 포워딩으로 SOCKS 프록시를 만듭니다.
  • -N은 원격 명령을 실행하지 않고, -f는 백그라운드로 보냅니다. 터널 전용 연결에 함께 씁니다.

포워딩은 강력한 만큼 통제해야 합니다. 서버 쪽에서 다음 지시어로 제한합니다.

AllowTcpForwarding no
AllowAgentForwarding no
GatewayPorts no
PermitOpen 10.0.5.20:5432

에이전트 포워딩(-A)은 특히 신중해야 합니다. 경유 서버의 root 권한을 가진 사람이 여러분의 에이전트 소켓을 통해 여러분의 키로 다른 서버에 인증할 수 있습니다. 배스천 경유가 목적이라면 ProxyJump를 쓰세요. 에이전트를 노출하지 않고 같은 결과를 얻습니다.

PermitOpen은 포워딩을 완전히 막지 않으면서 목적지를 화이트리스트로 제한합니다. 개발자가 운영 DB에는 붙어야 하지만 다른 내부 서비스에는 접근하면 안 되는 상황에 적합합니다. 여러 목적지를 공백으로 나열할 수 있고, none으로 지정하면 모든 포워딩 요청을 거부합니다.

파일 전송은 별도 도구가 아니라 같은 채널을 씁니다. 최근 OpenSSH의 scp는 내부적으로 SFTP 프로토콜을 사용하도록 바뀌었기 때문에, 과거에 통하던 일부 경로 확장 동작이 달라졌습니다. 대량 동기화라면 rsync를 SSH 위에서 돌리는 편이 재개와 부분 전송 면에서 유리합니다.

scp -i ~/.ssh/id_ed25519 ./app.tar.gz deploy@10.0.3.14:/tmp/
sftp -J bastion deploy@10.0.3.14
rsync -avz -e 'ssh -J bastion' ./dist/ deploy@10.0.3.14:/srv/app/

7. 접속이 안 될 때 — 계층별 진단 순서

문제를 계층으로 나누면 원인이 빨리 좁혀집니다.

1단계 — 네트워크가 닿는가.

nc -vz 10.0.3.14 22
ss -tlnp | grep ':22'

2단계 — 서버가 무엇을 제시하는가.

ssh -vvv deploy@10.0.3.14 2>&1 | head -60

-v는 한 단계, -vv, -vvv로 갈수록 자세해집니다. 읽는 요령은 다음과 같습니다.

  • debug1: Connecting to ...까지 못 가면 네트워크나 방화벽 문제입니다.
  • debug1: Offering public key: ... 다음에 Authentications that can continue가 반복되면 서버가 그 키를 거부한 것입니다.
  • Permission denied (publickey)는 인증 실패이고, Connection refused는 데몬 미기동, Connection timed out은 경로 차단입니다. 이 세 메시지의 구분이 진단의 절반입니다.

3단계 — 서버 쪽 로그를 본다.

sudo journalctl -u sshd -n 100 --no-pager
sudo journalctl -u sshd --since '10 min ago' -g 'Failed|Invalid|Accepted'

RHEL 계열의 유닛 이름은 sshd, Debian/Ubuntu 계열은 ssh인 경우가 많습니다. 시스템에 맞는 이름을 쓰세요.

4단계 — 권한과 SELinux.

sudo ls -ld /home/deploy /home/deploy/.ssh
sudo ls -l /home/deploy/.ssh/authorized_keys
sudo ausearch -m avc -ts recent | tail -20
sudo restorecon -Rv /home/deploy/.ssh

RHEL 계열에서 홈 디렉터리를 수동으로 만들었거나 파일을 다른 경로에서 복사한 경우, SELinux 컨텍스트가 틀려 키가 거부됩니다. restorecon이 표준 해법입니다. 이 증상은 로그에 권한 문제로 명확히 찍히지 않는 경우가 많아 시간을 많이 잡아먹습니다.

5단계 — 계정 상태.

sudo passwd -S deploy
sudo chage -l deploy
getent group sshusers

비밀번호가 잠긴 계정은 배포판 설정에 따라 키 인증까지 막힐 수 있습니다. 만료된 계정도 같은 결과를 냅니다.


8. 감사와 운영 관행

누가 언제 들어왔는지 추적할 수 있어야 합니다.

last -a | head -20
lastb -a | head -20
sudo journalctl -u sshd -g 'Accepted' --since '7 days ago' | tail -40

LogLevel VERBOSE를 켜 두면 인증에 사용된 키 지문이 함께 기록되므로, "이 접속은 어느 키로 이루어졌는가" 를 사후에 확인할 수 있습니다. 이 한 줄이 있고 없고가 사고 조사에서 큰 차이를 만듭니다.

무차별 대입 대응은 세 방향입니다. 첫째, 비밀번호 인증을 끄면 대부분의 자동화 공격은 무의미해집니다. 둘째, 접근 자체를 출발지로 제한합니다. 셋째, 실패 임계치 기반 차단 도구를 씁니다. 포트를 22에서 바꾸는 것은 로그 소음을 줄여 줄 뿐 보안 대책이 아니라는 점을 분명히 해 두는 편이 좋습니다.

정기 점검 항목을 목록으로 두면 놓치지 않습니다.

  • 각 서버의 authorized_keys에 있는 키가 현재 재직자·현재 시스템의 것인지 분기별 확인
  • 호스트 키 지문 목록을 별도 저장소에 보관
  • sshd -T 출력을 구성 관리로 고정하고 변경을 추적
  • 배스천 로그를 중앙 수집 대상에 포함
  • 하드웨어 보안 키 또는 SSH 인증서로의 전환 계획

서버 대수가 늘면 사람의 규율로는 유지되지 않습니다. 다음 단계로 넘어갈 시점을 판단하는 기준을 정해 두면 좋습니다. 서버가 열 대를 넘으면 authorized_keys를 구성 관리로 배포하고, 사람이 스무 명을 넘으면 SSH 인증서로 전환하며, 감사 요건이 생기면 배스천을 단일 진입점으로 강제하고 세션 기록을 남깁니다. 이 세 단계를 미루면 나중에 한꺼번에 하게 되고, 그때는 이미 어느 서버에 어떤 키가 있는지 아무도 모르는 상태가 되어 있습니다.

방화벽으로 SSH 접근을 제한하는 방법은 이 시리즈의 방화벽과 접근 제어 가이드에서 다룹니다. 특히 원격에서 규칙을 바꾸다 잠기는 사고를 막는 절차를 함께 읽어 두세요.


퀴즈: 실력을 확인해 보세요

퀴즈 1: 공개 키를 authorized_keys에 넣었는데 계속 비밀번호를 묻습니다. 가장 먼저 확인할 것은?

정답: 홈 디렉터리와 .ssh 디렉터리, authorized_keys 파일의 권한입니다

설명: sshd는 권한이 느슨하면 키를 조용히 무시합니다. 홈 디렉터리가 그룹 쓰기 가능이어도 거부됩니다.

chmod 755 ~
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

그래도 안 되면 서버 로그와 클라이언트 상세 로그를 함께 봅니다.

ssh -vvv deploy@10.0.3.14 2>&1 | grep -i -E 'offering|authentications that can continue'
sudo journalctl -u sshd -n 50 --no-pager

RHEL 계열이라면 SELinux 컨텍스트도 확인하세요. restorecon -Rv ~/.ssh로 해결되는 경우가 많습니다.

퀴즈 2: 키가 여러 개인데 특정 서버에서만 "Too many authentication failures"가 납니다. 원인과 해결은?

정답: 에이전트의 키를 순서대로 모두 시도하다 서버의 MaxAuthTries에 걸린 것입니다. IdentitiesOnly로 해결합니다

설명: MaxAuthTries의 기본값은 6입니다. 에이전트에 키가 10개 올라가 있으면 목표 키에 도달하기 전에 연결이 끊깁니다.

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_deploy deploy@10.0.3.14

영구적으로는 클라이언트 설정에 넣습니다.

Host prod-*
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
퀴즈 3: sshd_config를 원격에서 수정해야 합니다. 스스로 잠기지 않으려면 어떤 순서로 작업하나요?

정답: 기존 세션을 유지한 채 문법 검사 후 reload하고, 새 세션으로 접속을 확인한 뒤에만 기존 세션을 닫습니다

설명: reload는 이미 연결된 세션을 끊지 않습니다. 그래서 기존 세션이 안전망 역할을 합니다.

sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak-2026-08-15
sudo sshd -t
sudo systemctl reload sshd
sudo sshd -T | grep -i -E 'permitrootlogin|passwordauthentication|allowgroups'

그리고 다른 터미널에서 접속을 확인합니다. 실패하면 살아 있는 기존 세션에서 백업을 되돌립니다. 클라우드라면 시리얼 콘솔 접근 수단을 미리 확보해 두는 것도 중요합니다.

퀴즈 4: 배스천을 거쳐 내부 서버에 접속해야 합니다. 에이전트 포워딩과 ProxyJump 중 무엇을 쓰고 왜 그런가요?

정답: ProxyJump를 씁니다. 에이전트 포워딩은 배스천 관리자에게 키 사용 권한을 노출합니다

설명: 에이전트 포워딩(-A)을 쓰면 배스천에 에이전트 소켓이 노출됩니다. 그 서버의 root 권한을 가진 사람은 소켓을 통해 여러분의 키로 다른 서버에 인증할 수 있습니다. ProxyJump는 배스천을 단순 통로로만 쓰고 인증은 종단 간에 이루어집니다.

ssh -J bastion deploy@10.0.3.14
Host prod-*
  ProxyJump bastion
퀴즈 5: 퇴사자의 접근을 차단했습니다. authorized_keys에서 키를 지웠는데 충분할까요?

정답: 충분하지 않습니다. 이미 열려 있는 세션은 그대로 유지됩니다

설명: 키 제거는 새 인증을 막을 뿐입니다. 진행 중인 세션과, 그 세션이 만들어 둔 포워딩 터널은 살아 있습니다.

who
sudo pkill -TERM -u leaver sshd
sudo passwd -l leaver

또한 그 사람이 배포 자동화나 다른 서버에 자신의 키를 넣어 두었을 수 있으므로 전체 서버를 훑어야 합니다. 이런 이유로 SSH 인증서와 짧은 유효 기간으로 전환하면 회수 문제가 구조적으로 해결됩니다.

퀴즈 6: 접속 시도에서 "Connection timed out"과 "Connection refused"는 각각 무엇을 뜻하나요?

정답: timed out은 패킷이 목적지에 도달하지 못한 것(경로·방화벽), refused는 도달했으나 그 포트에 대기 중인 서비스가 없는 것입니다

설명: 이 구분이 진단 시간을 크게 줄입니다.

nc -vz 10.0.3.14 22
ss -tlnp | grep ':22'
sudo systemctl status sshd

refused라면 서버까지는 도달했으므로 방화벽이 아니라 데몬 상태나 바인딩 주소를 봅니다. timed out이라면 보안 그룹, 라우팅, 호스트 방화벽 순으로 확인합니다.


마치며

SSH 운영에서 사고는 대부분 두 가지 형태로 옵니다. 접근이 필요한 사람이 못 들어오거나, 접근이 끊겼어야 할 사람이 여전히 들어옵니다. 앞의 문제는 시끄럽게 드러나고 뒤의 문제는 조용히 남습니다. 그래서 감사와 회수 절차가 접속 편의보다 우선입니다.

당장 적용할 것을 세 가지로 줄이면 이렇습니다. 비밀번호 인증과 키보드 대화형 인증을 끄고, LogLevel VERBOSE로 키 지문을 남기고, 설정 변경은 항상 기존 세션을 살려 둔 채 새 세션으로 검증하세요. 이 세 가지만으로 대부분의 사고를 막을 수 있습니다.


참고 자료


이어서 읽기

The complete guide to SSH operations: from key management to hardening a server without locking yourself out

Introduction

SSH is easy to learn and hard to operate. Back when getting a shell was the whole job, ssh user@host was enough. The moment you run dozens of servers and an audit requirement lands on you, the questions change. Who owns the keys, how do you cut off access for someone who left, how do you standardise the route through the bastion, and what do you do when a bad configuration change locks you out.

This blog already has an in-depth analysis of the SSH protocol. That post covers the internals: the transport layer, authentication methods, channels, certificates, the Terrapin attack. This post goes in the opposite direction. The subject here is configuration and procedure for someone who does not need to know the protocol but does need to run a hundred servers safely.

The baseline is OpenSSH 9.x or newer. Option availability varies by version, so if a directive mentioned here does not work, check the version installed on the server first.

ssh -V
sshd -V

1. Creating keys — which type to choose

Two choices are current. Ed25519 is the default pick, and RSA 4096 is only for when organisational policy such as FIPS compliance requires it.

ssh-keygen -t ed25519 -C 'youngju@laptop-2026' -f ~/.ssh/id_ed25519
ssh-keygen -t rsa -b 4096 -C 'youngju@laptop-2026' -f ~/.ssh/id_rsa
ssh-keygen -t ecdsa-sk -f ~/.ssh/id_ecdsa_sk
ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk
  • -t is the key type, -b the bit length (meaningful for RSA only), -C the comment, -f the output file.
  • The types carrying the -sk suffix require a FIDO2 hardware security key. The private key itself never leaves the hardware, so the risk of leaking it drops sharply. Supported from OpenSSH 8.2 onward.

Getting into the habit of naming both the person and the machine in the comment matters. Once authorized_keys has twenty lines in it, that comment is the only clue to which line belongs to whom.

Always put a passphrase on a key. If automation makes that hard, use an agent.

ssh-keygen -p -f ~/.ssh/id_ed25519
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-add -l
ssh-add -D

ssh-add -D removes every key loaded into the agent. Make running it a habit when you walk away from a shared workstation.

Fingerprint checks are what you use for key distribution and for audits.

ssh-keygen -lf ~/.ssh/id_ed25519.pub
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

2. Distributing and revoking keys — working with authorized_keys

ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@10.0.3.14

ssh-copy-id is convenient, but once the server count grows it stops being a management method. You need to move to a configuration management tool (Ansible, Salt, and so on) or to SSH certificates. If you manage the files directly, the permissions have to be exact. When permissions are too loose, sshd silently rejects the key, and that is the single most common cause of "I installed the key and it still asks for a password".

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

A home directory that is group-writable gets rejected too.

chmod 755 ~

Each line in authorized_keys can carry restrictions. That is especially useful for keys with a fixed purpose, such as deployment keys.

restrict,from="10.0.0.0/8",command="/usr/local/bin/deploy-only" ssh-ed25519 AAAAC3Nza... deploy@ci
  • restrict is the safe default that turns every feature off. You then turn back on only what you need.
  • from= limits where the connection may originate.
  • command= runs only the specified command no matter what the client asks for.

Revocation does not end with deleting a line from a file. Sessions that are already open stay alive. A complete revocation goes in this order.

sudo -u deploy sed -i '/deploy@ci/d' /home/deploy/.ssh/authorized_keys
who
sudo pkill -TERM -u deploy sshd

Destructive command warning: the last command kills every SSH session belonging to that user. Your own session may be among them, so double-check the user and the target account.


3. Client configuration — ~/.ssh/config is the standard document

Writing down how to connect in a file rather than in somebody's memory reduces mistakes across the whole team.

Host bastion
  HostName bastion.example.com
  User youngju
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes
  ServerAliveInterval 30
  ServerAliveCountMax 3

Host prod-*
  User deploy
  ProxyJump bastion
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
  StrictHostKeyChecking yes

Host prod-web-01
  HostName 10.0.3.14

The key directives mean the following.

  • ProxyJump (-J) routes through the bastion. It is available from OpenSSH 7.3 onward and is far safer and shorter than the ProxyCommand combinations people used before.
  • IdentitiesOnly yes makes the client try only the key you named. Without it, the client tries every key loaded in the agent one after another and trips the server side MaxAuthTries (default 6), so authentication fails. This is the classic problem for anyone who holds several keys.
  • ServerAliveInterval and ServerAliveCountMax stop an idle connection from being dropped silently by NAT or a firewall.

The same thing works from the command line.

ssh -J bastion deploy@10.0.3.14
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_deploy deploy@10.0.3.14

Connection reuse makes repeated connections dramatically faster.

Host *
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 10m

ControlPersist is how long the master connection is kept after the last session exits. Note, though, that on a shared workstation another user could reuse the socket, so check the permissions on the socket path.


4. Host key verification — do not dismiss the warning

The host key warning is the only defence you have against a man-in-the-middle attack. But it also appears every time a server is reinstalled, so people learn to dismiss it reflexively. That habit is the danger.

ssh-keygen -F 10.0.3.14
ssh-keygen -R 10.0.3.14
ssh-keyscan -t ed25519 10.0.3.14
  • -F finds an entry in known_hosts and -R removes it.
  • ssh-keyscan fetches the host key from the server. Trusting what it fetched without verifying it is pointless, so you have to compare the fingerprint against the one shown on the server console.

Once you reach a certain scale, moving to SSH certificates is the right answer. If host keys are signed by a CA, the client only has to trust the one CA.

@cert-authority *.example.com ssh-ed25519 AAAAC3Nza...

User keys can be signed the same way. That removes the need to manage authorized_keys on every server, and a short validity period solves the revocation problem structurally. The issuing procedure and the important options depend on how your organisation implements its CA, so check the certificate-related options of ssh-keygen in the man page of the version you have installed.


5. Hardening sshd — in an order that does not lock you out

When you change server configuration, the order is the most important thing. Get it wrong and you lock yourself out.

The safe procedure is this.

  1. Keep the current session open and open one more terminal.
  2. Edit the configuration file.
  3. Check the syntax with sshd -t.
  4. Reload the service.
  5. Do not close the existing session; confirm you can connect from the new terminal.
  6. Only after that confirmation, close the original session.
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak-2026-08-15
sudo vi /etc/ssh/sshd_config
sudo sshd -t
sudo systemctl reload sshd

sshd -t checks syntax only. To see the values that will actually take effect, use extended test mode.

sudo sshd -T | sort | head -40
sudo sshd -T -C user=deploy,host=10.0.3.14,addr=10.0.3.14 | grep -i -E 'passwordauth|pubkey|permitroot'

-T prints the entire effective configuration and -C shows how the Match blocks resolve for a specific connection. If you use Match blocks, verify them with this command without exception. Conditions match differently from what people expect surprisingly often.

Here is a recommended configuration. The default value quoted for each directive is the one from the man page.

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowGroups sshusers
X11Forwarding no
PermitEmptyPasswords no
LogLevel VERBOSE

What each value means.

  • The default for PermitRootLogin is prohibit-password. In other words even out of the box, password login for root is blocked while key login is allowed. Set it to no to block it entirely.
  • The default for PasswordAuthentication is yes. Once you have moved to key authentication you must set it explicitly to no.
  • The default for KbdInteractiveAuthentication is also yes. People sometimes think they blocked passwords and traffic still comes in through this path, so turn it off as well. Its old name, ChallengeResponseAuthentication, is a deprecated alias.
  • The default for MaxAuthTries is 6, and failures start being written to the log once they reach half of that value.
  • The default for LoginGraceTime is 120 seconds. It is generally better to shorten it so that connections which never log in are not held open for long.
  • With AllowGroups, only users in the named groups may log in. DenyGroups is processed first, then AllowGroups.
  • LogLevel VERBOSE records the fingerprint of the key used for authentication. In an environment with audit requirements it is effectively mandatory.

Use Match blocks for exceptions.

Match Group sftponly
  ChrootDirectory /srv/sftp/%u
  ForceCommand internal-sftp
  AllowTcpForwarding no
  PermitTunnel no

When its condition matches, Match overrides the settings up to the next Match or the end of the file. If the same keyword is satisfied by several Match blocks, only the first one applies. Order changes the result, so be careful.

Configuration is split up differently depending on the distribution. Recent Debian/Ubuntu and RHEL-family systems read fragment files via Include /etc/ssh/sshd_config.d/*.conf. If an included file defines a value earlier on, settings further down can be ignored, which is why it matters to make checking effective values with sshd -T a habit before you change anything.


6. Port forwarding and tunnels

ssh -L 15432:db.internal:5432 bastion
ssh -R 8080:localhost:3000 relay.example.com
ssh -D 1080 bastion
ssh -N -f -L 15432:db.internal:5432 bastion
  • -L is local forwarding. A connection arriving on port 15432 of my machine is sent through the SSH server to db.internal:5432. You use it to reach a database on an internal network.
  • -R is remote forwarding. It pulls a port on the server back to my machine. By default it binds only to the loopback address on the server; to expose it externally you have to enable GatewayPorts on the server.
  • -D is dynamic forwarding and creates a SOCKS proxy.
  • -N runs no remote command and -f sends the client to the background. You use them together for tunnel-only connections.

Forwarding is powerful, which is exactly why it needs to be controlled. Restrict it on the server side with these directives.

AllowTcpForwarding no
AllowAgentForwarding no
GatewayPorts no
PermitOpen 10.0.5.20:5432

Agent forwarding (-A) deserves particular caution. Anyone with root on the intermediate server can use your agent socket to authenticate to other servers as you. If the goal is going through a bastion, use ProxyJump. You get the same result without exposing the agent.

PermitOpen restricts destinations to a whitelist without blocking forwarding outright. It fits the situation where a developer has to reach the production database but must not reach other internal services. You can list several destinations separated by spaces, and setting it to none refuses every forwarding request.

File transfer does not use a separate tool; it uses the same channel. Recent OpenSSH changed scp to use the SFTP protocol internally, so some path expansion behaviour that used to work has changed. For bulk synchronisation, running rsync over SSH is better for resuming and for partial transfers.

scp -i ~/.ssh/id_ed25519 ./app.tar.gz deploy@10.0.3.14:/tmp/
sftp -J bastion deploy@10.0.3.14
rsync -avz -e 'ssh -J bastion' ./dist/ deploy@10.0.3.14:/srv/app/

7. When the connection fails — a layered diagnostic order

Splitting the problem into layers narrows the cause quickly.

Step 1 — can the network reach it.

nc -vz 10.0.3.14 22
ss -tlnp | grep ':22'

Step 2 — what does the server offer.

ssh -vvv deploy@10.0.3.14 2>&1 | head -60

-v gives one level, and -vv and -vvv get progressively more detailed. Here is how to read the output.

  • If you never get as far as debug1: Connecting to ..., it is a network or firewall problem.
  • If Authentications that can continue repeats after debug1: Offering public key: ..., the server rejected that key.
  • Permission denied (publickey) is an authentication failure, Connection refused means the daemon is not running, and Connection timed out means the path is blocked. Telling those three messages apart is half of the diagnosis.

Step 3 — read the server-side log.

sudo journalctl -u sshd -n 100 --no-pager
sudo journalctl -u sshd --since '10 min ago' -g 'Failed|Invalid|Accepted'

On RHEL-family systems the unit is usually named sshd, while on Debian/Ubuntu it is often ssh. Use the name that matches your system.

Step 4 — permissions and SELinux.

sudo ls -ld /home/deploy /home/deploy/.ssh
sudo ls -l /home/deploy/.ssh/authorized_keys
sudo ausearch -m avc -ts recent | tail -20
sudo restorecon -Rv /home/deploy/.ssh

On RHEL-family systems, if the home directory was created by hand or the files were copied in from another path, the SELinux context is wrong and the key is rejected. restorecon is the standard fix. This symptom often does not show up clearly in the log as a permission problem, so it eats a lot of time.

Step 5 — account state.

sudo passwd -S deploy
sudo chage -l deploy
getent group sshusers

An account whose password is locked can, depending on the distribution settings, be blocked even for key authentication. An expired account produces the same result.


8. Auditing and operational practice

You have to be able to trace who came in and when.

last -a | head -20
lastb -a | head -20
sudo journalctl -u sshd -g 'Accepted' --since '7 days ago' | tail -40

With LogLevel VERBOSE enabled, the fingerprint of the key used for authentication is recorded alongside the event, so you can answer which key was this connection made with after the fact. Having that one line or not having it makes an enormous difference during an incident investigation.

There are three directions for dealing with brute force. First, turning off password authentication makes most automated attacks pointless. Second, restrict access itself by source address. Third, use a tool that blocks based on a failure threshold. It is worth stating plainly that moving off port 22 only reduces log noise and is not a security measure.

Keeping a list of periodic review items stops things slipping through.

  • Quarterly confirmation that the keys in each server's authorized_keys belong to current staff and current systems
  • Host key fingerprint lists kept in a separate repository
  • sshd -T output pinned by configuration management, with changes tracked
  • Bastion logs included in central collection
  • A plan for moving to hardware security keys or SSH certificates

Once the server count grows, human discipline will not hold the line. It helps to fix in advance the criteria for deciding when to move to the next stage. Past ten servers, distribute authorized_keys through configuration management; past twenty people, move to SSH certificates; once audit requirements appear, force the bastion as the single entry point and record sessions. Defer these three steps and you end up doing them all at once, by which point nobody knows which key is on which server any more.

Restricting SSH access with a firewall is covered in this series in the firewall and access control guide. Read the procedure there for avoiding the accident of locking yourself out while changing rules remotely.


Quiz: check your understanding

Quiz 1: You added the public key to authorized_keys but it keeps asking for a password. What do you check first?

Answer: The permissions on the home directory, the .ssh directory, and the authorized_keys file

Why: sshd silently ignores keys when permissions are too loose. A home directory that is group-writable gets rejected too.

chmod 755 ~
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

If that still does not fix it, read the server log and the verbose client log side by side.

ssh -vvv deploy@10.0.3.14 2>&1 | grep -i -E 'offering|authentications that can continue'
sudo journalctl -u sshd -n 50 --no-pager

On a RHEL-family system, check the SELinux context as well. restorecon -Rv ~/.ssh fixes it in many cases.

Quiz 2: You have several keys and one particular server returns "Too many authentication failures". What is the cause and the fix?

Answer: The client tried every key in the agent in order and tripped the server side MaxAuthTries. Fix it with IdentitiesOnly

Why: The default for MaxAuthTries is 6. If ten keys are loaded in the agent, the connection is cut before you reach the key you wanted.

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_deploy deploy@10.0.3.14

For a permanent fix, put it in the client configuration.

Host prod-*
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
Quiz 3: You have to edit sshd_config remotely. In what order do you work so you do not lock yourself out?

Answer: Keep the existing session open, check syntax, reload, verify a new session connects, and only then close the original session

Why: A reload does not cut sessions that are already connected. That is what makes the existing session a safety net.

sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak-2026-08-15
sudo sshd -t
sudo systemctl reload sshd
sudo sshd -T | grep -i -E 'permitrootlogin|passwordauthentication|allowgroups'

Then confirm the connection from a different terminal. If it fails, roll back the backup from the surviving original session. In the cloud, arranging serial console access in advance matters too.

Quiz 4: You need to reach an internal server through a bastion. Do you use agent forwarding or ProxyJump, and why?

Answer: Use ProxyJump. Agent forwarding exposes the right to use your key to whoever administers the bastion

Why: With agent forwarding (-A), your agent socket is exposed on the bastion. Anyone with root on that server can use the socket to authenticate to other servers as you. ProxyJump uses the bastion as nothing more than a conduit, and authentication happens end to end.

ssh -J bastion deploy@10.0.3.14
Host prod-*
  ProxyJump bastion
Quiz 5: You have cut off a departing employee. You deleted the key from authorized_keys — is that enough?

Answer: It is not enough. Sessions that are already open stay alive

Why: Removing the key only blocks new authentication. Sessions in progress, and the forwarding tunnels those sessions set up, are still running.

who
sudo pkill -TERM -u leaver sshd
sudo passwd -l leaver

That person may also have placed their key in deployment automation or on other servers, so you have to sweep every server. This is exactly why moving to SSH certificates with short validity periods solves the revocation problem structurally.

Quiz 6: On a connection attempt, what do "Connection timed out" and "Connection refused" each mean?

Answer: Timed out means the packet never reached the destination (routing or firewall); refused means it arrived but no service is listening on that port

Why: This distinction cuts diagnosis time considerably.

nc -vz 10.0.3.14 22
ss -tlnp | grep ':22'
sudo systemctl status sshd

If it is refused, you reached the server, so look at the daemon state or the bind address rather than the firewall. If it is timed out, check the security group, then routing, then the host firewall, in that order.


Closing

Incidents in SSH operations arrive in essentially two shapes. Either somebody who needs access cannot get in, or somebody whose access should have been cut is still getting in. The first problem announces itself loudly; the second sits there quietly. That is why auditing and revocation procedures come before convenience of access.

Boiled down to three things you can apply today: turn off password authentication and keyboard-interactive authentication, record key fingerprints with LogLevel VERBOSE, and always validate a configuration change from a new session while the old one is still alive. Those three alone prevent most incidents.


References


Further reading