<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Totp – Serverküche</title><link>https://serverkueche.de/en/tags/totp/</link><description>Totp – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Tue, 13 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/totp/index.xml" rel="self" type="application/rss+xml"/><item><title>Securing SSH with Two-Factor Authentication (TOTP)</title><link>https://serverkueche.de/en/tutorials/ssh-2fa-totp/</link><pubDate>Tue, 13 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/ssh-2fa-totp/</guid><description>Protect SSH logins with a key plus a one-time code: set up libpam-google-authenticator, configure PAM and sshd correctly – without locking yourself out.</description><content:encoded><![CDATA[<p>An SSH key is strong – as long as nobody has your laptop. A second factor closes exactly that gap:
whoever logs in needs the key <strong>and</strong> a six-digit code from an authenticator app. We add it step by
step – and put up a safety net from the start so you don&rsquo;t lock yourself out.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>At the end your server requires two independent factors for SSH logins: the private key and a
time-based one-time code (TOTP) from an app such as Aegis, FreeOTP or Google Authenticator. The code
is checked by <strong>PAM</strong>, not by SSH itself – which is why two configurations have to line up.</p>
<p>Built and verified on <strong>Debian 13 with OpenSSH 10.0p2</strong> and
<code>libpam-google-authenticator 20191231-2.1</code>. We proceed in two stages: first a single test user, then
everyone. And we verify each step <strong>before</strong> the existing login goes away.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>The rule for this article: keep a second session open
  </p>
  <div class="prose-kitchen text-sm">Everything changed here can lock you out of your server. So keep <strong>a second SSH session open</strong> for the
entire setup, one you are already logged into – an existing connection survives even if you break the
login configuration. That lets you undo any change. Anyone who additionally has a console at their
provider (with netcup the VNC console in the SCP) is on the safe side.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A Debian 13 server whose <a href="/en/tutorials/harden-ssh/">SSH access is already hardened</a>: key-based
login, no root login with a password</li>
<li>An authenticator app on your phone. Recommended: <strong>Aegis</strong> (Android) or <strong>FreeOTP</strong> – both open and
without forced cloud sync</li>
<li>Access to an emergency console at your provider (VNC in the netcup SCP) in case something does go
wrong</li>
<li>About 40 minutes, unhurried – this is not a step for „in between&quot;</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/ssh-2fa-totp/" data-content-piece="VPS 1000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Runs on any server – the VNC console in the SCP is the real safety net here.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200"
     data-voucher-api="vps-1000" data-voucher-msg-empty="No code is available for this plan right now – use the €5 code." data-voucher-msg-limit="No more codes today – use the €5 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5 code.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Show code</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p>
  <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
    Single use, valid for VPS 1000 G12.5. Redeem it in an empty cart – the code adds the server itself.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="1-install-the-pam-module">1. Install the PAM module</h3>
<p>The TOTP code is checked by a PAM module, not by the SSH server:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt install libpam-google-authenticator</span></span></code></pre></div>
</div>
<p>The name is misleading: the module speaks the open <strong>TOTP</strong> standard (RFC 6238) and needs neither a
Google account nor internet access. Any standard authenticator app works.</p>
<p>Also handy is <code>pamtester</code> – it lets you verify the login <strong>without</strong> having to open a new SSH session:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install pamtester</span></span></code></pre></div>
</div>
<h3 id="2-create-a-secret-for-one-user">2. Create a secret for one user</h3>
<p>Log in as the user who should get 2FA (not as root – the secret belongs in their home directory) and
run:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">google-authenticator -t -d -f -r <span class="m">3</span> -R <span class="m">30</span> -W</span></span></code></pre></div>
</div>
<p>The flags are deliberate and save you five interactive questions. One remains, and that is a good thing – more on it in a moment:</p>
<ul>
<li><strong><code>-t</code></strong> time-based codes (TOTP) instead of counter-based ones</li>
<li><strong><code>-d</code></strong> forbids reusing a code – against shoulder surfing</li>
<li><strong><code>-f</code></strong> writes the file without first asking whether it should</li>
<li><strong><code>-r 3 -R 30</code></strong> rate limiting: at most 3 attempts per 30 seconds</li>
<li><strong><code>-W</code></strong> a tighter time window (less tolerance, but safer)</li>
</ul>
<p>The tool shows a QR code and the secret in plain text. Scan the QR code with your app – then it
asks <code>Enter code from app (-1 to skip):</code>. Type in the code the app shows right now: it&rsquo;s the first
check that app and server compute the same thing, and you shouldn&rsquo;t skip it. Only then does the
tool write the file and show the <strong>emergency scratch codes</strong>. <strong>Write them down on paper</strong> – they
are your way back if the phone is lost.</p>
<p>That prompt is also why the command can&rsquo;t run unattended in a script: without a terminal it aborts
with <code>getline(): Inappropriate ioctl for device</code> and creates <strong>no</strong> file.</p>
<p>Afterwards the configuration sits in the home directory:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -l ~/.google_authenticator <span class="o">&amp;&amp;</span> grep <span class="s1">&#39;^&#34;&#39;</span> ~/.google_authenticator</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">-r-------- 1 koch koch 135 Jul 25 09:14 /home/koch/.google_authenticator
</span></span><span class="line"><span class="cl">&#34; RATE_LIMIT 3 30
</span></span><span class="line"><span class="cl">&#34; WINDOW_SIZE 3
</span></span><span class="line"><span class="cl">&#34; DISALLOW_REUSE
</span></span><span class="line"><span class="cl">&#34; TOTP_AUTH</span></span></code></pre></div>
</div>
<p>Permissions <code>0400</code>, owned by the user themselves – that is how it should be. Whoever can read this
file can generate codes.</p>
<h3 id="3-configure-pam--with-nullok-as-the-safety-net">3. Configure PAM – with <code>nullok</code> as the safety net</h3>
<p>Open <code>/etc/pam.d/sshd</code> and put this as the <strong>first <code>auth</code> line</strong>:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">auth required pam_google_authenticator.so nullok</span></span></span></code></pre></div>
</div>
<p>That <code>nullok</code> is the most important part of this article: it means „users <strong>without</strong>
<code>~/.google_authenticator</code> may still get in without a code&quot;. So you lock nobody out while rolling 2FA
out – and you stay in yourself as long as you have not created a secret.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>nullok has to go again at the end
  </p>
  <div class="prose-kitchen text-sm"><code>nullok</code> is a ramp, not a state: as long as it is there, any user can bypass the second factor simply
by not creating a secret. Once all accounts are set up, remove the word – but not before, and
<strong>verify first</strong> with <code>pamtester</code> (step 6) that the login works for every user.</div>
</div>
<h3 id="4-sshd-allow-keyboard-interactive-authentication">4. sshd: allow keyboard-interactive authentication</h3>
<p>SSH asks for the code through the „keyboard-interactive&quot; mechanism, and on Debian 13 that is <strong>off</strong>
by default. Instead of editing the main file, add a drop-in – that way it survives package updates
untouched:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;KbdInteractiveAuthentication yes&#34;</span> <span class="p">|</span> sudo tee /etc/ssh/sshd_config.d/60-totp.conf</span></span></code></pre></div>
</div>
<p>Check the configuration <strong>before</strong> reloading – a syntax error would otherwise cost you your access:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sshd -t <span class="o">&amp;&amp;</span> sudo systemctl reload ssh</span></span></code></pre></div>
</div>
<p>Confirm what is actually in effect now:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sshd -T <span class="p">|</span> grep -E <span class="s2">&#34;kbdinteractive|usepam&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">usepam yes
</span></span><span class="line"><span class="cl">kbdinteractiveauthentication yes</span></span></code></pre></div>
</div>
<h3 id="5-enforce-two-factors--for-one-user-first">5. Enforce two factors – for one user first</h3>
<p>Now the actual switch: <code>AuthenticationMethods</code> demands <strong>several</strong> methods in sequence. So that a
mistake doesn&rsquo;t hit every account, the rule initially applies to one test user only:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/ssh/sshd_config.d/61-totp-user.conf</span>
</span></span><span class="line"><span class="cl"><span class="na">Match User YOUR_TEST_USER</span>
</span></span><span class="line"><span class="cl">    <span class="na">AuthenticationMethods publickey,keyboard-interactive</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sshd -t <span class="o">&amp;&amp;</span> sudo systemctl reload ssh</span></span></code></pre></div>
</div>
<p>What applies to whom is revealed by <code>sshd -T</code> with a user context – a command that saves a lot of
guesswork:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sshd -T -C <span class="nv">user</span><span class="o">=</span>YOUR_TEST_USER,host<span class="o">=</span>localhost,addr<span class="o">=</span>127.0.0.1 <span class="p">|</span> grep authenticationmethods
</span></span><span class="line"><span class="cl">sudo sshd -T -C <span class="nv">user</span><span class="o">=</span>root,host<span class="o">=</span>localhost,addr<span class="o">=</span>127.0.0.1 <span class="p">|</span> grep authenticationmethods</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">authenticationmethods publickey,keyboard-interactive
</span></span><span class="line"><span class="cl">authenticationmethods any</span></span></code></pre></div>
</div>
<p>Exactly what we wanted: the test user needs both, everyone else is untouched.</p>
<h3 id="6-verify-without-locking-yourself-out">6. Verify without locking yourself out</h3>
<p>Before you risk a new SSH session, test the PAM stack directly. <code>pamtester</code> asks exactly what SSH
would ask:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pamtester -v sshd YOUR_TEST_USER authenticate</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pamtester: invoking pam_start(sshd, koch2, ...)
</span></span><span class="line"><span class="cl">pamtester: performing operation - authenticate
</span></span><span class="line"><span class="cl">Verification code: Password: pamtester: successfully authenticated</span></span></code></pre></div>
</div>
<p>Two observations that matter in a moment: the <strong>code is asked for first</strong>, then the <strong>password</strong> – the
order follows from the position in the PAM stack. And with a wrong code the same command answers:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Verification code: Password: pamtester: Authentication failure</span></span></code></pre></div>
</div>
<p>Only once that works do you open a <strong>new</strong> session (leaving the old one open!) and log in as the test
user. The sequence: the key is accepted automatically, then the server asks for the code.</p>
<h3 id="7-the-stumbling-block-code-and-password">7. The stumbling block: code <em>and</em> password?</h3>
<p>In the default configuration the login demands <strong>both</strong> after the key – the one-time code and the
password. That is not SSH&rsquo;s fault but a consequence of <code>UsePAM yes</code>: <code>keyboard-interactive</code> runs the
<strong>entire</strong> PAM stack, first your TOTP module and then, via <code>@include common-auth</code>, the password check
as well.</p>
<p>The test was unambiguous: key + correct code + correct password → login successful; key + correct code</p>
<ul>
<li><strong>wrong</strong> password → <code>Permission denied (keyboard-interactive)</code>.</li>
</ul>
<p>If you want <strong>key + code without a password</strong> (the more common variant, since the password adds little
next to the key), comment out the password check in <code>/etc/pam.d/sshd</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">auth required pam_google_authenticator.so nullok</span>
</span></span><span class="line"><span class="cl"><span class="c1">#@include common-auth</span></span></span></code></pre></div>
</div>
<p>After that, key + code is enough – confirmed in the test, and a wrong code is still rejected.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Without common-auth there is no password login – for anyone
  </p>
  <div class="prose-kitchen text-sm">This single line affects <strong>all</strong> SSH logins. Verified: after commenting it out, even a login with the
<strong>correct</strong> password was rejected. Anyone who logs in with a password somewhere – a colleague, a
deployment script, your phone client – will be locked out from that moment on. Only take this step
once <strong>all</strong> access paths use keys, and with a second session open.</div>
</div>
<h3 id="8-roll-it-out-to-all-users">8. Roll it out to all users</h3>
<p>Once the test user works cleanly and every account has its secret, make the rule general: replace the
<code>Match</code> block with a global line and drop <code>nullok</code>.</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/ssh/sshd_config.d/60-totp.conf</span>
</span></span><span class="line"><span class="cl"><span class="na">KbdInteractiveAuthentication yes</span>
</span></span><span class="line"><span class="cl"><span class="na">AuthenticationMethods publickey,keyboard-interactive</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/pam.d/sshd – without nullok, so nobody can bypass the factor</span>
</span></span><span class="line"><span class="cl"><span class="na">auth required pam_google_authenticator.so</span></span></span></code></pre></div>
</div>
<p>And again: <code>sudo sshd -t</code>, reload, verify with <code>pamtester</code>, <strong>then</strong> test a new session – with the old
one still open.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Exempt automation
  </p>
  <div class="prose-kitchen text-sm"><p>Backup jobs, CI runners or deployments cannot type a code. Exempt them deliberately instead of dropping
2FA altogether:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">Match User deploy</span>
</span></span><span class="line"><span class="cl">    <span class="na">AuthenticationMethods publickey</span></span></span></code></pre></div>
</div>
<p>Such an account should then get its own restricted key (see the <code>command=</code> option in
<code>authorized_keys</code>) – not the same rights as your personal account.</p>
</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>No code prompt appears at all, the login works as before.</strong> Then <code>KbdInteractiveAuthentication</code> is
still <code>no</code> – the default on Debian 13. Check what really applies with
<code>sudo sshd -T | grep kbdinteractive</code>, and note that files in <code>/etc/ssh/sshd_config.d/</code> are read in
alphabetical order and the <strong>first</strong> setting wins.</p>
<p><strong>After the key you are also asked for the password.</strong> Not a bug but the consequence of <code>UsePAM yes</code>:
<code>keyboard-interactive</code> runs the whole PAM stack, including <code>@include common-auth</code>. If you only want key</p>
<ul>
<li>code, comment that line out – with the consequence from the warning in step 7.</li>
</ul>
<p><strong>The code is rejected even though the app shows it right now.</strong> Usually the server clock has drifted.
TOTP tolerates only a few seconds. Check <code>timedatectl</code> – it must say <code>System clock synchronized: yes</code>;
otherwise enable NTP. Second possibility: you used the same code shortly before and <code>DISALLOW_REUSE</code>
forbids that. This also applies if the previous attempt only failed at the <strong>password</strong> – the code
was correct and is therefore used up. In our test the same code was rejected right afterwards with
the correct password; the next one went through. So simply wait for the next one.</p>
<p><strong>After three failed attempts nothing works any more.</strong> That is the rate limit from <code>-r 3 -R 30</code>: three
attempts per 30 seconds. Every round counts: after a wrong password the SSH client asks again up to
three times, and that alone exhausts the limit. Wait a moment instead of typing on.</p>
<p><strong>One user gets in without a code.</strong> Either their <code>~/.google_authenticator</code> is missing and <code>nullok</code>
lets them pass, or a <code>Match</code> block with different <code>AuthenticationMethods</code> applies to them.
<code>sudo sshd -T -C user=NAME,host=localhost,addr=127.0.0.1 | grep authenticationmethods</code> shows what really
applies to them.</p>
<p><strong>Phone lost, no scratch codes.</strong> Then only an access path that does not go through SSH helps: the
provider&rsquo;s VNC console. Log in there and delete the user&rsquo;s file (<code>rm ~/.google_authenticator</code>) – with
<code>nullok</code> they get back in without a code, without it you end up with an account that has no second
factor and needs to be set up again.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Scratch codes belong in a backup – but not on the server.</strong> The five codes from step 2 are equivalent
to the second factor. Stored on the same server they would be pointless; in your
<a href="/en/tutorials/vaultwarden-password-manager/">password manager vault</a> or on paper in a drawer they
belong.</p>
<p><strong>Back up the secrets – or deliberately don&rsquo;t.</strong> <code>~/.google_authenticator</code> is part of the home
directory. If you back up home directories with <a href="/en/tutorials/restic-backups/">Restic</a>, the second
factor ends up in the backup: convenient when restoring, but an attacker with backup access then has
both factors. For most self-hosters that is an acceptable trade-off – you just want to have decided it
consciously rather than overlooked it.</p>
<p><strong>Re-check after every OpenSSH update.</strong> Options get renamed (<code>ChallengeResponseAuthentication</code> became
<code>KbdInteractiveAuthentication</code>), and old names disappear eventually. A
<code>sudo sshd -T | grep -E &quot;kbdinteractive|authenticationmethods&quot;</code> after the update shows in two seconds
whether your hardening still applies or has silently become ineffective. How to automate updates is
covered in
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic updates</a>.</p>
<p><strong>What 2FA does not do.</strong> The second factor protects the <em>login</em>. It does not help against a vulnerable
application behind your reverse proxy, a leaked Docker socket mount or an unpatched kernel. It is one
layer next to a <a href="/en/tutorials/firewall-ufw-setup/">firewall</a>,
<a href="/en/tutorials/fail2ban-setup/">fail2ban</a> and <a href="/en/tutorials/crowdsec-setup/">CrowdSec</a> – and it
replaces none of them.</p>
]]></content:encoded></item></channel></rss>