<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rafi Bagaskara Halilintar</title>
    <description>The latest articles on DEV Community by Rafi Bagaskara Halilintar (@masitings).</description>
    <link>https://dev.to/masitings</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F355442%2F84af0b9d-2cc7-4f18-8712-3be8a9e0e873.png</url>
      <title>DEV Community: Rafi Bagaskara Halilintar</title>
      <link>https://dev.to/masitings</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/masitings"/>
    <language>en</language>
    <item>
      <title>SSH Compression After OpenSSH 10.6: What Changed (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Fri, 13 Nov 2026 03:20:38 +0000</pubDate>
      <link>https://dev.to/masitings/ssh-compression-after-openssh-106-what-changed-2026-2hpm</link>
      <guid>https://dev.to/masitings/ssh-compression-after-openssh-106-what-changed-2026-2hpm</guid>
      <description>&lt;p&gt;If you have &lt;code&gt;Compression yes&lt;/code&gt; in your &lt;code&gt;~/.ssh/config&lt;/code&gt; or type &lt;code&gt;ssh -C&lt;/code&gt; out of habit, your transfers may have quietly grown. OpenSSH 10.6, released on October 6, 2026, disabled the LZ77 part of SSH compression to close a side channel. The &lt;code&gt;Compression&lt;/code&gt; option still exists and still negotiates &lt;code&gt;zlib@openssh.com&lt;/code&gt;, but it now does far less work.&lt;/p&gt;

&lt;p&gt;We wanted to know how much less, so we built OpenSSH 10.5p1 and 10.6p1 from source and pushed the same files through both. This guide covers what changed in the code, the numbers we measured, why the change was made, and what to use for bulk transfers now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What OpenSSH 10.6 changed in SSH compression, straight from the source code.&lt;/li&gt;
&lt;li&gt;Measured wire bytes for &lt;code&gt;ssh -C&lt;/code&gt; on text, JSON logs and random data, 10.5 vs 10.6.&lt;/li&gt;
&lt;li&gt;Why an older version number doesn't mean you still have the old behavior (Ubuntu 24.04 already backported it).&lt;/li&gt;
&lt;li&gt;The side channel in plain terms, and when compression is still unsafe even after the fix.&lt;/li&gt;
&lt;li&gt;What to use instead: &lt;code&gt;rsync -z&lt;/code&gt;, &lt;code&gt;zstd&lt;/code&gt; in a pipe, and how to check what a session negotiated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Did OpenSSH 10.6 make SSH compression weaker?
&lt;/h2&gt;

&lt;p&gt;Yes, on purpose. The 10.6 release notes list it under "Potentially-incompatible changes": "compression will be less effective as a result of the change noted below in the Security section." The security entry says &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;sshd&lt;/code&gt; now "disable LZ77 dictionary coder to mitigate the side-channel leaks" described in a 2026 paper titled "Crossing the Streams: SSH Plaintext Recovery via a Common Compression Context in Multiplexed Channels."&lt;/p&gt;

&lt;p&gt;In the code, the change is one call in &lt;code&gt;packet.c&lt;/code&gt;. OpenSSH 10.5 started outgoing compression with zlib's &lt;code&gt;deflateInit(stream, 6)&lt;/code&gt;, which is regular gzip-style deflate at level 6. OpenSSH 10.6 calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="nf"&gt;deflateInit2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;compression_out_stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;Z_BEST_SPEED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Z_DEFLATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Z_HUFFMAN_ONLY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Z_HUFFMAN_ONLY&lt;/code&gt; is a zlib strategy that skips string matching entirely. Deflate normally does two things: LZ77 finds repeated strings and replaces them with short back-references to earlier data, then Huffman coding gives frequent symbols shorter bit codes. With Huffman only, the second step remains and the first is gone. Repetition, which is where most of deflate's gains come from on logs, JSON and source code, no longer helps.&lt;/p&gt;

&lt;p&gt;The wire format is unchanged. Both ends still negotiate &lt;code&gt;zlib@openssh.com&lt;/code&gt;, and a Huffman-only stream is a valid deflate stream, so an old client can decompress what a new server sends and the other way around. Nothing breaks. It just gets bigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much bigger does ssh -C traffic get?
&lt;/h2&gt;

&lt;p&gt;We measured it. The setup: OpenSSH 10.5p1 and 10.6p1 built from the official portable tarballs against the same zlib and OpenSSL 3.0.13, each used as a client to upload a file to a local sshd with &lt;code&gt;Compression yes&lt;/code&gt;. Each run piped a file into &lt;code&gt;ssh ... 'cat &amp;gt; /dev/null'&lt;/code&gt; and read two lines from &lt;code&gt;ssh -v&lt;/code&gt; at exit: &lt;code&gt;Transferred: sent N&lt;/code&gt; and &lt;code&gt;compress outgoing: raw data X, compressed Y, factor F&lt;/code&gt;. Four test files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;text&lt;/strong&gt;: 16.2 MB of roff man page source from &lt;code&gt;/usr/share/man/man1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;seq&lt;/strong&gt;: 22.9 MB from &lt;code&gt;seq 1 3000000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;json&lt;/strong&gt;: 7.1 MB of JSON log lines (60,000 lines, same keys, varying values).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;random&lt;/strong&gt;: 20 MB from &lt;code&gt;/dev/urandom&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bytes on the wire with &lt;code&gt;ssh -C&lt;/code&gt; (the factor is compressed size divided by raw size):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;text&lt;/strong&gt;: 10.5 sent 4,338,600 bytes (factor 0.27). 10.6 sent 10,191,048 bytes (factor 0.63). That's 2.3 times more traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;seq&lt;/strong&gt;: 10.5 sent 6,139,392 (0.27). 10.6 sent 9,257,960 (0.40).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;json&lt;/strong&gt;: 10.5 sent 449,860 (0.06). 10.6 sent 4,218,588 (0.60). More than 9 times more traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;random&lt;/strong&gt;: both 1.00. Random data never compressed, and both runs sent a few kilobytes more than the uncompressed run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern matches the code change. Data with long repeated strings, like JSON logs where every line repeats the same keys, lost almost all of its benefit, going from 6% of the original size to 60%. Data with a skewed character distribution but less exact repetition, like digits and newlines, kept more. Already-compressed or encrypted data was never helped and still isn't.&lt;/p&gt;

&lt;p&gt;Two caveats. These are single runs on one machine, so treat the byte counts as solid and anything about speed as anecdotal. And compression applies per direction: each side compresses what it sends, so a 10.6 client uploading to an older server is weaker on the upload and unchanged on the download, until the server side is patched too.&lt;/p&gt;

&lt;p&gt;On loopback, where bandwidth is effectively free, &lt;code&gt;ssh -C&lt;/code&gt; only costs CPU. In our runs the 10.6 upload of the text file took less time than the 10.5 one, because Huffman-only is cheaper to compute than full deflate. On a real network the tradeoff is the opposite: you spend less CPU and send more bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't the version number tell you which behavior you have?
&lt;/h2&gt;

&lt;p&gt;Because distributions backport security fixes into older versions. Our test box runs Ubuntu 24.04, and its client still reports &lt;code&gt;OpenSSH_9.6p1 Ubuntu-3ubuntu13.20&lt;/code&gt;. Uploading the same text file with that client gave a factor of 0.63, identical to 10.6. Downloading from the Ubuntu sshd gave 0.63 as well.&lt;/p&gt;

&lt;p&gt;The package changelog explains it. The &lt;code&gt;1:9.6p1-3ubuntu13.20&lt;/code&gt; security update, dated October 8, 2026, includes "SECURITY UPDATE: incorrect use of LZ77 dictionary coder" with the note "Disable LZ77 dictionary coder" in &lt;code&gt;packet.c&lt;/code&gt;, tracked as CVE-2026-106582.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ssh -V&lt;/code&gt; is not a reliable check here. Other distributions may ship the backport on a different schedule, or not at all for older releases. Measure instead, as shown in the next section, or read your package changelog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zcat /usr/share/doc/openssh-client/changelog.Debian.gz &lt;span class="o"&gt;|&lt;/span&gt; grep -n -i lz77
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That path is Debian and Ubuntu specific. On RPM-based systems, look for a similar entry with &lt;code&gt;rpm -q --changelog openssh-clients | grep -i lz77&lt;/code&gt;; we only verified the Ubuntu package.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check what compression a session actually uses?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh -v&lt;/code&gt; prints the negotiated compression method for each direction during key exchange, and the achieved ratio when the connection closes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh -v -C user@host &lt;span class="nb"&gt;exit&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;|&lt;/span&gt; grep -E &lt;span class="s1"&gt;'compression:|compress (outgoing|incoming)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output from our lab, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug1: kex: server-&amp;gt;client cipher: chacha20-poly1305@openssh.com MAC: &amp;lt;implicit&amp;gt; compression: zlib@openssh.com
debug1: kex: client-&amp;gt;server cipher: chacha20-poly1305@openssh.com MAC: &amp;lt;implicit&amp;gt; compression: zlib@openssh.com
debug1: compress outgoing: raw data 123, compressed 140, factor 1.14
debug1: compress incoming: raw data 514, compressed 450, factor 0.88
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;compression: none&lt;/code&gt; means compression is off for that direction. &lt;code&gt;zlib@openssh.com&lt;/code&gt; means compression starts after authentication, which is the only mode current OpenSSH servers allow.&lt;/p&gt;

&lt;p&gt;The factor line is the useful one. For a quick test of your own path, push something repetitive through it and look at the outgoing factor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;seq 1 1000000 &lt;span class="o"&gt;|&lt;/span&gt; ssh -v -C user@host &lt;span class="s1"&gt;'cat &amp;gt; /dev/null'&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;|&lt;/span&gt; grep &lt;span class="s1"&gt;'compress outgoing'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;seq&lt;/code&gt; output, a pre-change client landed near 0.27 in our runs and a patched one near 0.40. For a real comparison, use a sample of the data you actually move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was the side channel, in plain terms?
&lt;/h2&gt;

&lt;p&gt;LZ77 saves space by spotting text it has already seen and writing "copy 20 bytes from 300 bytes back" instead of the text itself. In SSH, all channels of one connection share a single compression context. That includes your shell, an &lt;code&gt;scp&lt;/code&gt; transfer, and any port forward running over the same connection, including a ControlMaster-multiplexed one.&lt;/p&gt;

&lt;p&gt;Now suppose an attacker can push data into one of those channels, for example through a forwarded port that serves web traffic they can influence. They send a guess at a secret that is flowing through another channel, like a token in your shell session. If the guess matches what the compressor saw recently, it compresses into a short back-reference and the encrypted packet is a little smaller. If it doesn't, the packet is a little larger. Encryption hides the content, not the length. By watching packet sizes and refining guesses, the attacker can recover the secret piece by piece.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, it's the same family as the CRIME and BREACH attacks on TLS compression. The OpenSSH release notes describe it as a chosen-plaintext attack in which "attacker-controlled input can recognizably reflect into the total length of transmitted ciphertexts." Removing the dictionary coder removes the back-references the attack relies on.&lt;/p&gt;

&lt;p&gt;The man pages already warned about this before 10.6, and they still do. From &lt;code&gt;ssh_config(5)&lt;/code&gt;: "If untrusted traffic (such as an open port-forward) is permitted over the connection alongside trusted traffic, then compression may leak information about session contents. For this reason, it is not recommended to enable compression for connections that share trusted and untrusted traffic." That advice stands. Huffman-only coding still varies output size with the data, just much less, so don't treat 10.6 as a reason to turn compression back on for mixed-trust connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you use instead of ssh -C?
&lt;/h2&gt;

&lt;p&gt;The release notes give the direction: "Users are encouraged to use application-level compression over the SSH protocol where possible, as this will typically be more effective and will be completely immune to this type of attack." It's immune because the compressor only ever sees your one stream, never another channel's data. In our lab it was also much smaller than old &lt;code&gt;ssh -C&lt;/code&gt; ever was.&lt;/p&gt;

&lt;p&gt;All numbers below use the 10.6 client with &lt;code&gt;Compression=no&lt;/code&gt;, so the only compression is the application's own.&lt;/p&gt;

&lt;h3&gt;
  
  
  rsync -z
&lt;/h3&gt;

&lt;p&gt;rsync compresses file data itself when you pass &lt;code&gt;-z&lt;/code&gt;, and since rsync 3.2 both sides negotiate the algorithm from a list. Our rsync 3.2.7 build listed &lt;code&gt;zstd lz4 zlibx zlib none&lt;/code&gt;, and plain &lt;code&gt;-z&lt;/code&gt; picked zstd. Results for the 16.2 MB text file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no compression: 16,156,275 bytes sent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-z&lt;/code&gt; (negotiated zstd): 2,522,169 bytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--zc=zlib&lt;/code&gt;: 4,310,739 bytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--zc=lz4&lt;/code&gt;: 7,990,462 bytes
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync -az ./site/ user@host:/srv/site/
&lt;span class="c"&gt;# force an algorithm (implies -z); both ends must support it&lt;/span&gt;
rsync -a --zc=zstd ./site/ user@host:/srv/site/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--zc&lt;/code&gt; is short for &lt;code&gt;--compress-choice&lt;/code&gt;, and per the rsync man page it implies &lt;code&gt;--compress&lt;/code&gt; unless you pass &lt;code&gt;none&lt;/code&gt;. If the server's rsync is older and can't do the requested algorithm, it says so instead of silently falling back. Our &lt;a href="https://termique.app/blog/sftp-vs-rsync-automated-backups/" rel="noopener noreferrer"&gt;SFTP vs rsync guide&lt;/a&gt; covers the rest of the rsync side, including the &lt;code&gt;--delete&lt;/code&gt; trap.&lt;/p&gt;

&lt;h3&gt;
  
  
  zstd or gzip in a pipe
&lt;/h3&gt;

&lt;p&gt;For one-off copies of a directory, compress the tar stream yourself and decompress on the other side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tar -cf - ./logs &lt;span class="o"&gt;|&lt;/span&gt; zstd -T0 -3 -c &lt;span class="o"&gt;|&lt;/span&gt; ssh user@host &lt;span class="s1"&gt;'zstd -d -c | tar -xf - -C /srv/archive'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-T0&lt;/code&gt; uses all cores, &lt;code&gt;-3&lt;/code&gt; is zstd's default level, &lt;code&gt;-c&lt;/code&gt; writes to stdout and &lt;code&gt;-d&lt;/code&gt; decompresses. We ran this exact pipeline in the lab and compared the files with &lt;code&gt;cmp&lt;/code&gt;: identical. Wire bytes for single files, piped with &lt;code&gt;Compression=no&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;text, &lt;code&gt;gzip -6&lt;/code&gt;: 4,314,564 bytes. &lt;code&gt;zstd -3&lt;/code&gt;: 2,788,524.&lt;/li&gt;
&lt;li&gt;json, &lt;code&gt;gzip -6&lt;/code&gt;: 449,068 bytes. &lt;code&gt;zstd -3&lt;/code&gt;: 122,812.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;gzip -6&lt;/code&gt; lands almost exactly where pre-10.6 &lt;code&gt;ssh -C&lt;/code&gt; did, which makes sense: same deflate algorithm, same level. zstd at its default level beat both by a wide margin. &lt;code&gt;zstd&lt;/code&gt; has to exist on both ends. If it doesn't on the server, &lt;code&gt;gzip&lt;/code&gt; and &lt;code&gt;gunzip&lt;/code&gt; are everywhere.&lt;/p&gt;

&lt;p&gt;The same idea works for database dumps and logs: compress where the data is produced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@host &lt;span class="s1"&gt;'pg_dump mydb | zstd -c'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mydb.sql.zst
ssh user@host &lt;span class="s1"&gt;'journalctl -u nginx --since today | gzip -c'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; nginx.log.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Turn compression off where it does nothing
&lt;/h3&gt;

&lt;p&gt;If you have &lt;code&gt;Compression yes&lt;/code&gt; under &lt;code&gt;Host *&lt;/code&gt;, it now buys much less than it used to on most connections, and on fast links it never helped. The default is &lt;code&gt;no&lt;/code&gt;. A tidy config keeps compression off globally and enables it only where it still earns its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="nc"&gt;Host&lt;/span&gt; &lt;span class="n"&gt;slow&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;gw&lt;/span&gt;
    &lt;span class="nc"&gt;HostName&lt;/span&gt; &lt;span class="n"&gt;gw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slow&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;
    &lt;span class="nc"&gt;Compression&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;

&lt;span class="nc"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
    &lt;span class="nc"&gt;Compression&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh&lt;/code&gt; uses the first value it finds for each option, so the specific &lt;code&gt;Host&lt;/code&gt; block has to come before &lt;code&gt;Host *&lt;/code&gt;. Our &lt;a href="https://termique.app/blog/ssh-config-file-organization-tips-devops/" rel="noopener noreferrer"&gt;ssh config organization guide&lt;/a&gt; shows a layout that keeps that ordering readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is ssh -C still worth it?
&lt;/h2&gt;

&lt;p&gt;On genuinely slow links, a factor of 0.6 on text is still a 40% cut. The &lt;code&gt;ssh(1)&lt;/code&gt; man page has always framed it this way: compression "is desirable on modem lines and other slow connections, but will only slow down things on fast networks." A shell session over a tethered phone, a satellite link or a congested VPN can still feel better with it. For anything bulk, application-level compression wins on both size and security.&lt;/p&gt;

&lt;p&gt;Avoid it on connections that carry untrusted traffic next to trusted traffic. The usual cases are a jump host connection with port forwards for other people's traffic, or a multiplexed ControlMaster connection that mixes your shell with a forward to a public-facing service. If you route through a bastion, our &lt;a href="https://termique.app/blog/bastion-host-proxyjump-setup/" rel="noopener noreferrer"&gt;ProxyJump setup guide&lt;/a&gt; shows how to keep hops separate.&lt;/p&gt;

&lt;p&gt;On the server, &lt;code&gt;sshd_config&lt;/code&gt; takes &lt;code&gt;Compression yes&lt;/code&gt;, &lt;code&gt;delayed&lt;/code&gt; (a legacy synonym for &lt;code&gt;yes&lt;/code&gt;) or &lt;code&gt;no&lt;/code&gt;, with &lt;code&gt;yes&lt;/code&gt; as the default. A server that sets &lt;code&gt;no&lt;/code&gt; turns compression off for every client (sessions simply negotiate &lt;code&gt;none&lt;/code&gt;), which is a reasonable choice for a bastion that carries forwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does termique fit?
&lt;/h2&gt;

&lt;p&gt;termique is a GUI SSH manager with its own SSH stack, so &lt;code&gt;Compression&lt;/code&gt; lines in your &lt;code&gt;~/.ssh/config&lt;/code&gt; don't apply to it, and its docs don't list an SSH compression setting. The advice in this guide still applies inside termique's terminal: pipe through &lt;code&gt;zstd&lt;/code&gt; or use &lt;code&gt;rsync -z&lt;/code&gt; from the shell, and the compression happens in your application stream, not in the SSH layer.&lt;/p&gt;

&lt;p&gt;What termique adds is the part around the session. Port forwards (local, remote and SOCKS5) are configured per host rather than living in a shell history, which makes it easier to see which connections carry forwarded traffic. Your SSH passwords and private keys are end-to-end encrypted before they sync between devices, so the sync server only stores ciphertext. Hosts, groups and snippets sync as plaintext metadata. The details are on our &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt;, and the wider hardening checklist is in our &lt;a href="https://termique.app/blog/complete-guide-ssh-security-2026/" rel="noopener noreferrer"&gt;SSH security best practices guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is ssh -C slower after OpenSSH 10.6?
&lt;/h3&gt;

&lt;p&gt;Not on CPU: Huffman-only coding is cheaper than full deflate, and in our loopback test the 10.6 upload finished faster than 10.5. What changes is size. Repetitive data like JSON logs sent several times more bytes in our test, so on a slow link transfers that relied on &lt;code&gt;ssh -C&lt;/code&gt; take longer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Compression yes in ssh config still do anything?
&lt;/h3&gt;

&lt;p&gt;Yes. It still negotiates &lt;code&gt;zlib@openssh.com&lt;/code&gt; and applies Huffman coding, which reduced our text sample to 63% of its size and the &lt;code&gt;seq&lt;/code&gt; output to 40%. It no longer removes repetition, which was where most of the gain came from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is rsync -z or ssh -C better?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;rsync -z&lt;/code&gt;. In our test it sent 2.5 MB for a 16.2 MB text file with negotiated zstd, against 10.2 MB for &lt;code&gt;ssh -C&lt;/code&gt; on 10.6. It also compresses only rsync's own data, so it can't leak across SSH channels. Don't use both at once; compressing twice only costs CPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does SSH support zstd compression?
&lt;/h3&gt;

&lt;p&gt;Not in the SSH protocol as OpenSSH implements it. The only methods are &lt;code&gt;zlib@openssh.com&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt;. To get zstd over SSH, compress in the application: &lt;code&gt;rsync -z&lt;/code&gt; (which negotiates zstd when both ends have it) or a &lt;code&gt;zstd&lt;/code&gt; pipe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my ssh -C transfer bigger on Ubuntu 24.04 when ssh -V still says 9.6?
&lt;/h3&gt;

&lt;p&gt;Ubuntu backported the LZ77 change into its 9.6p1 package in the October 8, 2026 security update (&lt;code&gt;1:9.6p1-3ubuntu13.20&lt;/code&gt;, CVE-2026-106582). The version string stays 9.6 while the compression behavior matches 10.6.&lt;/p&gt;

&lt;p&gt;If you move files between more servers than you want to track by hand, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;try termique free&lt;/a&gt;: hosts and port forwards in one place, and credentials encrypted before they sync.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>SSH Certificate Authentication with ssh-keygen (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Wed, 11 Nov 2026 03:20:48 +0000</pubDate>
      <link>https://dev.to/masitings/ssh-certificate-authentication-with-ssh-keygen-2026-2mo0</link>
      <guid>https://dev.to/masitings/ssh-certificate-authentication-with-ssh-keygen-2026-2mo0</guid>
      <description>&lt;p&gt;You have forty devices in the field, three people who need shell access, and an &lt;code&gt;authorized_keys&lt;/code&gt; file on every one of them. Someone leaves, and you get to touch forty files. Someone gets a new laptop, and you get to touch forty files again. A device gets reflashed, and everyone's terminal shouts that the host key changed.&lt;/p&gt;

&lt;p&gt;SSH certificate authentication fixes both directions of that problem with tools you already have. One CA key signs user keys, every server trusts that one CA, and a second CA signs host keys so clients stop asking "are you sure you want to continue connecting?". No extra daemon, no vendor, just &lt;code&gt;ssh-keygen&lt;/code&gt; and two lines of &lt;code&gt;sshd_config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide is the hands-on version. If you're still deciding whether certificates are worth it for your team, read &lt;a href="https://termique.app/blog/ssh-certificate-authority-vs-public-key-auth-teams/" rel="noopener noreferrer"&gt;SSH certificate authorities vs public key auth for teams&lt;/a&gt; first. Every command below was run against OpenSSH 9.6 while writing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How SSH certificate authentication works, in one paragraph.&lt;/li&gt;
&lt;li&gt;How to create a user CA and sign a short-lived user certificate with &lt;code&gt;ssh-keygen -s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;How to make &lt;code&gt;sshd&lt;/code&gt; trust the CA with &lt;code&gt;TrustedUserCAKeys&lt;/code&gt; and map roles with &lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;How host certificates replace the trust-on-first-use prompt with one &lt;code&gt;@cert-authority&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;What "Certificate invalid: name is not a listed principal" means, in both places it shows up.&lt;/li&gt;
&lt;li&gt;How to revoke a certificate early with a KRL, and how to roll this out without locking yourself out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How does SSH certificate authentication work?
&lt;/h2&gt;

&lt;p&gt;A user still has a normal key pair. Instead of copying the public key to every server, you sign it with a CA key. The signature produces a third file, &lt;code&gt;id_ed25519-cert.pub&lt;/code&gt;, which contains the user's public key plus metadata: a key ID, a serial number, a list of principals (the account or role names it is valid for), a validity window and a set of permissions.&lt;/p&gt;

&lt;p&gt;The server holds only the CA's public key. When a user connects with a certificate, &lt;code&gt;sshd&lt;/code&gt; checks three things: the CA signature is valid, the certificate is inside its validity window, and one of its principals is allowed for the account being logged into. If all three pass, the user is in. No &lt;code&gt;authorized_keys&lt;/code&gt; entry is involved.&lt;/p&gt;

&lt;p&gt;Host certificates are the same idea in reverse. A host CA signs each server's host key, and clients trust the host CA instead of remembering individual host keys in &lt;code&gt;known_hosts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OpenSSH certificates are not X.509. The ssh-keygen man page calls them "a different, and much simpler, format". You don't need OpenSSL, a PKI team or a CRL server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before you start?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OpenSSH on clients and servers. Certificate support has been in OpenSSH for well over a decade, so any supported distro is fine.&lt;/li&gt;
&lt;li&gt;A machine to hold the CA private keys. Treat it like a root password: ideally offline or a locked-down signing host, never a server in the fleet.&lt;/li&gt;
&lt;li&gt;A naming plan for principals. Principals are just strings. Roles work better than people: &lt;code&gt;fleet-ops&lt;/code&gt;, &lt;code&gt;deploy&lt;/code&gt;, &lt;code&gt;readonly&lt;/code&gt;. You decide on each server which roles may log into which account.&lt;/li&gt;
&lt;li&gt;One SSH session left open on a test server while you change &lt;code&gt;sshd_config&lt;/code&gt;. You will want it if something goes wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you create the SSH certificate authority keys?
&lt;/h2&gt;

&lt;p&gt;Create two CA keys, one for users and one for hosts. Keeping them separate means a leak of one doesn't let an attacker forge the other kind of certificate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -t ed25519 -f user_ca -C &lt;span class="s2"&gt;"user-ca 2026"&lt;/span&gt;
ssh-keygen -t ed25519 -f host_ca -C &lt;span class="s2"&gt;"host-ca 2026"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give both a strong passphrase when prompted. You now have &lt;code&gt;user_ca&lt;/code&gt; and &lt;code&gt;host_ca&lt;/code&gt; (private, stay on the signing machine) and &lt;code&gt;user_ca.pub&lt;/code&gt; and &lt;code&gt;host_ca.pub&lt;/code&gt; (public, go to servers and clients).&lt;/p&gt;

&lt;p&gt;Ed25519 is a sensible default for a new CA. If you have very old devices that only understand RSA, check what they support before you pick; a CA nobody can verify is not much use.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you sign a user certificate with ssh-keygen -s?
&lt;/h2&gt;

&lt;p&gt;The user generates a key pair as usual and sends you only the &lt;code&gt;.pub&lt;/code&gt; file. You sign it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -s user_ca \
  -I ops-laptop-03 \
  -n fleet-ops,deploy \
  -V -5m:+8h \
  -z 1042 \
  ops_key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each flag does, per the ssh-keygen man page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-s user_ca&lt;/code&gt;: sign with this CA private key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-I ops-laptop-03&lt;/code&gt;: the key ID. It is written to the server's auth log on every login and can be used to revoke the certificate. Make it something you can trace back to a device or a request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-n fleet-ops,deploy&lt;/code&gt;: the principals. Without at least one principal, &lt;code&gt;sshd&lt;/code&gt; refuses the certificate when it is trusted via &lt;code&gt;TrustedUserCAKeys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-V -5m:+8h&lt;/code&gt;: valid from five minutes ago until eight hours from now. The five-minute back-dating absorbs small clock drift between the signing machine and your devices.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-z 1042&lt;/code&gt;: a serial number. It defaults to zero, so set it yourself; serials make revocation precise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is &lt;code&gt;ops_key-cert.pub&lt;/code&gt;, next to the key. Inspect any certificate with &lt;code&gt;ssh-keygen -L&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ssh-keygen -L -f ops_key-cert.pub
ops_key-cert.pub:
        Type: ssh-ed25519-cert-v01@openssh.com user certificate
        Public key: ED25519-CERT SHA256:ohGkf96o...
        Signing CA: ED25519 SHA256:msQOMhsA... (using ssh-ed25519)
        Key ID: "ops-laptop-03"
        Serial: 1042
        Valid: from 2026-10-20T20:57:52 to 2026-10-21T05:02:52
        Principals:
                fleet-ops
                deploy
        Critical Options: (none)
        Extensions:
                permit-X11-forwarding
                permit-agent-forwarding
                permit-port-forwarding
                permit-pty
                permit-user-rc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Can you restrict what a certificate is allowed to do?
&lt;/h3&gt;

&lt;p&gt;Yes, with &lt;code&gt;-O&lt;/code&gt;. By default a user certificate permits X11 forwarding, agent forwarding, port forwarding, a PTY and &lt;code&gt;~/.ssh/rc&lt;/code&gt;. For a tighter certificate, clear everything and add back what you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -s user_ca -I ops-laptop-03 -n fleet-ops -V +8h \
  -O clear -O permit-pty ops_key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two critical options are worth knowing for automation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-O source-address=10.0.0.0/8&lt;/code&gt;: the certificate only works from these CIDR ranges.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-O force-command=/usr/local/bin/backup.sh&lt;/code&gt;: whatever the client asks for, this command runs instead. Useful for a backup job's certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Order matters with &lt;code&gt;clear&lt;/code&gt;: put it before the &lt;code&gt;permit-*&lt;/code&gt; options, or it wipes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you configure sshd to trust the CA?
&lt;/h2&gt;

&lt;p&gt;Copy &lt;code&gt;user_ca.pub&lt;/code&gt; to each server and add this to &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="nc"&gt;TrustedUserCAKeys&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;etc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;user_ca&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;
&lt;span class="nc"&gt;AuthorizedPrincipalsFile&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;etc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;auth_principals&lt;/span&gt;&lt;span class="o"&gt;/%&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TrustedUserCAKeys&lt;/code&gt; tells &lt;code&gt;sshd&lt;/code&gt; which CAs may sign user certificates. The file can hold more than one CA public key, one per line, which you will need when you rotate the CA.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt; maps principals to local accounts. &lt;code&gt;%u&lt;/code&gt; expands to the username being logged into, so the file for the &lt;code&gt;ops&lt;/code&gt; account is &lt;code&gt;/etc/ssh/auth_principals/ops&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fleet-ops
deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any certificate carrying &lt;code&gt;fleet-ops&lt;/code&gt; or &lt;code&gt;deploy&lt;/code&gt; as a principal can now log in as &lt;code&gt;ops&lt;/code&gt;. Without an &lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt;, the rule is stricter and less flexible: the certificate must list the literal username as a principal. That works for a single account, but role names scale better across a fleet where every device has the same &lt;code&gt;ops&lt;/code&gt; and &lt;code&gt;deploy&lt;/code&gt; users.&lt;/p&gt;

&lt;p&gt;Validate the config before reloading, from the session you kept open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sudo sshd -t &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sudo systemctl reload ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service is called &lt;code&gt;ssh&lt;/code&gt; on Debian and Ubuntu and &lt;code&gt;sshd&lt;/code&gt; on most other distros. Now open a second terminal and log in. Don't close the first one until the second works.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the client use the certificate?
&lt;/h2&gt;

&lt;p&gt;Mostly, it just works. If the certificate sits next to the key with the &lt;code&gt;-cert.pub&lt;/code&gt; suffix, &lt;code&gt;ssh&lt;/code&gt; loads it automatically for any &lt;code&gt;IdentityFile&lt;/code&gt; you use. You can also point at it explicitly in &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="nc"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fleet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;
    &lt;span class="nc"&gt;IdentityFile&lt;/span&gt; &lt;span class="o"&gt;~/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ops_key&lt;/span&gt;
    &lt;span class="nc"&gt;CertificateFile&lt;/span&gt; &lt;span class="o"&gt;~/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ops_key&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use ssh-agent, &lt;code&gt;ssh-add&lt;/code&gt; picks up the certificate along with the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ ssh-add ~/.ssh/ops_key
Identity added: /home/you/.ssh/ops_key &lt;span class="o"&gt;(&lt;/span&gt;ops-laptop&lt;span class="o"&gt;)&lt;/span&gt;
Certificate added: /home/you/.ssh/ops_key-cert.pub &lt;span class="o"&gt;(&lt;/span&gt;ops-laptop-03&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an eight-hour certificate expires, the key is still fine. The user gets a fresh signature and carries on. That renewal step is the part teams usually automate later, with a small signing service or a tool like step-ca, which the &lt;a href="https://termique.app/blog/ssh-certificate-authority-vs-public-key-auth-teams/" rel="noopener noreferrer"&gt;comparison post&lt;/a&gt; walks through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do host certificates get rid of the "authenticity of host" prompt?
&lt;/h2&gt;

&lt;p&gt;On each device, sign its existing host public key with the host CA. Note the &lt;code&gt;-h&lt;/code&gt; flag, which makes it a host certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -s host_ca \
  -I device-042 \
  -h \
  -n device-042.fleet.example.com,10.20.0.42 \
  -V +52w \
  /etc/ssh/ssh_host_ed25519_key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For host certificates, principals are the names clients will connect with: hostnames and IPs. Then tell &lt;code&gt;sshd&lt;/code&gt; to present it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="nc"&gt;HostCertificate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;etc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;ssh_host_ed25519_key&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every client, one line in &lt;code&gt;~/.ssh/known_hosts&lt;/code&gt; (or &lt;code&gt;/etc/ssh/ssh_known_hosts&lt;/code&gt; for all users) replaces forty individual entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="nd"&gt;@cert&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fleet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;10.20&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ed25519&lt;/span&gt; &lt;span class="nc"&gt;AAAAC3NzaC1lZDI1NTE5AAAA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ca&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a reflashed device with a new host key and a fresh certificate connects without a warning, and an impostor without a valid certificate still fails. If you want the background on why that first-connection prompt exists at all, see &lt;a href="https://termique.app/blog/ssh-host-key-verification-explained/" rel="noopener noreferrer"&gt;SSH host key verification explained&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does SSH say "Certificate invalid: name is not a listed principal"?
&lt;/h2&gt;

&lt;p&gt;This message shows up in two different places, and they mean different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the client, before login.&lt;/strong&gt; The server presented a host certificate, but the name you typed isn't one of its principals. You signed the cert for &lt;code&gt;device-042.fleet.example.com&lt;/code&gt; and connected to &lt;code&gt;device-042&lt;/code&gt; or to an IP you didn't list. OpenSSH prints the error and falls back to normal host key checking, so you get the old trust-on-first-use prompt again (or "Host key verification failed" if &lt;code&gt;StrictHostKeyChecking&lt;/code&gt; is on). Fix: re-sign the host key with every name people actually use in &lt;code&gt;-n&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the server's auth log.&lt;/strong&gt; The user certificate's principals don't include the account name, and there's no &lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt; to map them. The client sees only &lt;code&gt;Permission denied (publickey)&lt;/code&gt;. Fix: add the username to the certificate's principals, or set up a principals file for that account.&lt;/p&gt;

&lt;p&gt;If you do use an &lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt;, the server-side wording changes. A certificate whose principals aren't in the file is logged as &lt;code&gt;Certificate does not contain an authorized principal&lt;/code&gt;. Check the file for the account you're logging into, and check for typos; principals are exact string matches.&lt;/p&gt;

&lt;p&gt;Two quick ways to see what's going on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -L -f ~/.ssh/ops_key-cert.pub   &lt;span class="c"&gt;# what the cert actually says&lt;/span&gt;
ssh -v ops@device-042.fleet.example.com     &lt;span class="c"&gt;# which cert the client offers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you revoke an SSH certificate before it expires?
&lt;/h2&gt;

&lt;p&gt;Short validity is your first line of defense: an eight-hour certificate that leaks is useless by tomorrow. For anything longer lived, or when you can't wait, use a Key Revocation List (KRL).&lt;/p&gt;

&lt;p&gt;Write a small spec file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1042&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="n"&gt;laptop&lt;/span&gt;&lt;span class="mi"&gt;-03&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the KRL with the CA public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -k -f revoked.krl -s user_ca.pub krl.spec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add more entries later, use &lt;code&gt;-u&lt;/code&gt; to update instead of replacing. To check whether a given certificate is revoked, &lt;code&gt;-Q&lt;/code&gt; exits non-zero if it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen -Q -f revoked.krl ops_key-cert.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distribute &lt;code&gt;revoked.krl&lt;/code&gt; to every server and reference it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RevokedKeys /etc/ssh/revoked.krl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One warning from the sshd_config man page: if the &lt;code&gt;RevokedKeys&lt;/code&gt; file is not readable, public key authentication is refused for all users. Deploy the file before the config line, and keep its permissions right.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you roll SSH certificates out to a fleet without locking yourself out?
&lt;/h2&gt;

&lt;p&gt;Do it in an order where every step is reversible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Push &lt;code&gt;user_ca.pub&lt;/code&gt;, the principals files and the two &lt;code&gt;sshd_config&lt;/code&gt; lines to a handful of devices. Leave &lt;code&gt;authorized_keys&lt;/code&gt; alone. Both methods now work side by side.&lt;/li&gt;
&lt;li&gt;Issue certificates to the people who need access and confirm they can log in with the certificate alone (&lt;code&gt;ssh -o IdentitiesOnly=yes -i ~/.ssh/ops_key ...&lt;/code&gt; with no matching &lt;code&gt;authorized_keys&lt;/code&gt; entry).&lt;/li&gt;
&lt;li&gt;Roll the same config to the rest of the fleet with whatever you already use: Ansible, your image build, cloud-init.&lt;/li&gt;
&lt;li&gt;Only then remove the old public keys from &lt;code&gt;authorized_keys&lt;/code&gt;. Keep one break-glass key, stored offline, for the day the CA machine is unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For CA rotation later: add the new CA public key as a second line in &lt;code&gt;TrustedUserCAKeys&lt;/code&gt;, start signing with the new CA, wait until every old certificate has expired, then remove the old line. With short-lived certificates, that wait is hours, not months. The same overlap pattern is covered in more detail in &lt;a href="https://termique.app/blog/ssh-key-rotation-best-practices/" rel="noopener noreferrer"&gt;SSH key rotation best practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an SSH certificate setup overkill?
&lt;/h2&gt;

&lt;p&gt;Honestly, for a lot of people. If you have five servers and two people, a well-kept &lt;code&gt;authorized_keys&lt;/code&gt; and one key per device is fewer moving parts than a CA. You also take on new responsibilities: protecting the CA key, keeping clocks roughly in sync across the fleet (validity checks depend on it), and building a renewal habit so nobody is locked out at 2 AM because their certificate expired at midnight.&lt;/p&gt;

&lt;p&gt;Certificates start paying off when one of these is true: the number of devices grows faster than you can edit files, people join and leave regularly, devices get reflashed often, or an auditor wants to know who logged in where. That last one is a quiet win: every login logs the key ID and serial, so "which certificate was used" is in the auth log by default. For the broader picture of team access, &lt;a href="https://termique.app/blog/ssh-key-management-for-teams/" rel="noopener noreferrer"&gt;SSH key management for teams&lt;/a&gt; covers the options side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does termique fit?
&lt;/h2&gt;

&lt;p&gt;termique is a desktop and mobile SSH manager, so it sits on the client side of this picture, not the CA side. Its documentation covers key-pair authentication: generating ed25519 or RSA keys, importing existing keys in PEM or OpenSSH format, and storing private keys encrypted on your device before anything syncs. The docs don't describe certificate support, so for logins that depend on a &lt;code&gt;-cert.pub&lt;/code&gt; file, use OpenSSH directly as shown above.&lt;/p&gt;

&lt;p&gt;Where it helps around a certificate rollout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single host list with groups, synced across your devices. Free covers 3 hosts; a whole fleet needs Pro, which removes the host cap.&lt;/li&gt;
&lt;li&gt;Keys and passwords for the hosts still on plain key auth, including your break-glass key, encrypted end to end so the sync server only stores ciphertext. Host entries, groups and snippets sync as plaintext metadata; the &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt; spells out exactly what is and isn't encrypted.&lt;/li&gt;
&lt;li&gt;Per-host command logs on Pro, which pair well with the key ID in &lt;code&gt;sshd&lt;/code&gt;'s auth log when you need to answer "who ran what". The post on &lt;a href="https://termique.app/blog/per-command-audit-logs-shared-ssh-access/" rel="noopener noreferrer"&gt;per-command audit logs for shared SSH access&lt;/a&gt; explains how that works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the CA private keys out of every day-to-day tool, termique included. They belong on the signing machine and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between an SSH certificate and an SSH key?
&lt;/h3&gt;

&lt;p&gt;An SSH key is a key pair the server trusts because its public half is listed in &lt;code&gt;authorized_keys&lt;/code&gt;. An SSH certificate is that same public key plus a CA signature, principals, a validity window and permissions. The server trusts the CA instead of each key, so adding or removing a user no longer means editing every server.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should an SSH user certificate be valid?
&lt;/h3&gt;

&lt;p&gt;As short as your renewal process allows. A working day (&lt;code&gt;-V -5m:+8h&lt;/code&gt;) is a common choice for people, because a stolen certificate expires before most incidents are even noticed. Automation that can renew itself can go shorter. Long-lived user certificates bring back the revocation problem certificates were meant to solve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do SSH certificates work with ssh-agent?
&lt;/h3&gt;

&lt;p&gt;Yes. When you run &lt;code&gt;ssh-add&lt;/code&gt; on a key that has a matching &lt;code&gt;-cert.pub&lt;/code&gt; file next to it, the agent loads both, and &lt;code&gt;ssh-add -l&lt;/code&gt; lists the key and the certificate as separate entries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one certificate log into different usernames on different servers?
&lt;/h3&gt;

&lt;p&gt;Yes, that's what principals plus &lt;code&gt;AuthorizedPrincipalsFile&lt;/code&gt; are for. Give the certificate a role principal like &lt;code&gt;fleet-ops&lt;/code&gt;, then list &lt;code&gt;fleet-ops&lt;/code&gt; in &lt;code&gt;/etc/ssh/auth_principals/ops&lt;/code&gt; on the devices where that role should be able to log in as &lt;code&gt;ops&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need step-ca or Vault to use SSH certificates?
&lt;/h3&gt;

&lt;p&gt;No. Everything in this guide uses &lt;code&gt;ssh-keygen&lt;/code&gt; and &lt;code&gt;sshd&lt;/code&gt; alone. Tools like step-ca or Vault add automated issuance, short-lived certificates through SSO and an API, which matter once manual signing becomes a bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try termique
&lt;/h2&gt;

&lt;p&gt;Certificates handle trust for the fleet. You still need a sane way to keep track of hosts, the keys that remain and what was run where. termique is a free, native SSH manager with end-to-end encrypted credentials, on desktop and mobile. &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;Download termique&lt;/a&gt; and add your first hosts.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Permission Denied (publickey): How to Fix It (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Fri, 06 Nov 2026 03:20:53 +0000</pubDate>
      <link>https://dev.to/masitings/permission-denied-publickey-how-to-fix-it-2026-2341</link>
      <guid>https://dev.to/masitings/permission-denied-publickey-how-to-fix-it-2026-2341</guid>
      <description>&lt;p&gt;You added your key to the server, you run &lt;code&gt;ssh&lt;/code&gt;, and you get one line back: &lt;code&gt;Permission denied (publickey).&lt;/code&gt; No hint about which key, which file, or which side is wrong. That's on purpose. The server tells an unauthenticated client as little as possible, so the real reason sits in two places you have to go and read: the client's verbose output and the server's auth log.&lt;/p&gt;

&lt;p&gt;This guide walks through both. Every error message below was reproduced on OpenSSH 9.6 against a throwaway &lt;code&gt;sshd&lt;/code&gt; on localhost while writing this, so the strings match what you'll actually see. If your error says &lt;code&gt;Connection refused&lt;/code&gt; or &lt;code&gt;Connection timed out&lt;/code&gt; instead, you never reached authentication; start with &lt;a href="https://termique.app/blog/ssh-connection-refused-vs-timeout/" rel="noopener noreferrer"&gt;connection refused vs timeout&lt;/a&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What &lt;code&gt;Permission denied (publickey)&lt;/code&gt; means, and the one thing it rules out.&lt;/li&gt;
&lt;li&gt;How to read &lt;code&gt;ssh -v&lt;/code&gt; to see which keys your client offered.&lt;/li&gt;
&lt;li&gt;The sshd log lines that name the actual cause.&lt;/li&gt;
&lt;li&gt;The usual fixes: wrong user, wrong key, file modes, a key ssh refuses to load, and old &lt;code&gt;ssh-rsa&lt;/code&gt; signatures.&lt;/li&gt;
&lt;li&gt;The GitHub, GitLab and Bitbucket version of the error.&lt;/li&gt;
&lt;li&gt;A short checklist you can run top to bottom.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does "Permission denied (publickey)" mean?
&lt;/h2&gt;

&lt;p&gt;It means the connection worked, the SSH handshake worked, and authentication failed. The word in brackets is the list of methods the server still accepts. &lt;code&gt;(publickey)&lt;/code&gt; alone means the server has password login turned off, so a key is the only way in, and none of the keys your client offered were accepted for that user.&lt;/p&gt;

&lt;p&gt;That leaves a short list of causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're logging in as the wrong user, so the server checks the wrong &lt;code&gt;authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your client offered a key, but not the one the server has.&lt;/li&gt;
&lt;li&gt;The server has your key but refuses to read the file because of its permissions.&lt;/li&gt;
&lt;li&gt;Your client never offered the key at all (it couldn't load it, or it skipped it).&lt;/li&gt;
&lt;li&gt;The key type or signature algorithm isn't accepted on one side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You find out which one in about a minute with &lt;code&gt;ssh -v&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you see which key ssh actually offered?
&lt;/h2&gt;

&lt;p&gt;Run the same command with &lt;code&gt;-v&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-v&lt;/span&gt; deploy@203.0.113.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip the key exchange noise and look for the lines after &lt;code&gt;Authentications that can continue&lt;/code&gt;. A successful login in our lab looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug1: Authentications that can continue: publickey
debug1: Next authentication method: publickey
debug1: Will attempt key: good ED25519 SHA256:o7brbztlmr26oaVCi4sa6/VOiLZsMtjuTmXb/UsHdCE explicit
debug1: Offering public key: good ED25519 SHA256:o7brbztlmr26oaVCi4sa6/VOiLZsMtjuTmXb/UsHdCE explicit
debug1: Server accepts key: good ED25519 SHA256:o7brbztlmr26oaVCi4sa6/VOiLZsMtjuTmXb/UsHdCE explicit
Authenticated to 127.0.0.1 ([127.0.0.1]:2299) using "publickey".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is the same server with the wrong key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug1: Will attempt key: other ED25519 SHA256:R/2QZex7ZnX4f8qdaw07KYYfJHDWgSA88ufboBsdnMk explicit
debug1: Offering public key: other ED25519 SHA256:R/2QZex7ZnX4f8qdaw07KYYfJHDWgSA88ufboBsdnMk explicit
debug1: Authentications that can continue: publickey
debug1: No more authentication methods to try.
root@127.0.0.1: Permission denied (publickey).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three patterns cover almost every case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Offering public key&lt;/code&gt; followed by &lt;code&gt;Authentications that can continue&lt;/code&gt;&lt;/strong&gt;: the key was sent and the server said no. Either it's the wrong key, or the server can't read its file. Go to the server log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;Offering public key&lt;/code&gt; line at all&lt;/strong&gt;: your client never sent a key. Look for a warning above it (bad permissions, file not found) or a &lt;code&gt;Skipping&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Offering&lt;/code&gt; lines for keys you didn't expect&lt;/strong&gt;: ssh is trying agent keys or defaults before the one you meant. Pin the key (see below), and if you see &lt;code&gt;Too many authentication failures&lt;/code&gt;, that's the same problem with more keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;Will attempt key&lt;/code&gt; lists every candidate in order, with its SHA-256 fingerprint. Keep that fingerprint; you'll compare it with the server side in a moment.&lt;/p&gt;

&lt;p&gt;To check what your client will use before connecting, &lt;code&gt;ssh -G&lt;/code&gt; prints the resolved config without opening a connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-G&lt;/span&gt; deploy@203.0.113.10 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(user|hostname|port|identityfile|identitiesonly) '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no &lt;code&gt;IdentityFile&lt;/code&gt; configured, it lists the defaults it will look for (&lt;code&gt;~/.ssh/id_rsa&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ecdsa&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; and a few more). If your key has any other filename, ssh won't find it unless you tell it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the server checking the right user and the right file?
&lt;/h2&gt;

&lt;p&gt;The client can't tell you why the server said no. The server's auth log can. On systemd distros:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; ssh &lt;span class="nt"&gt;-n&lt;/span&gt; 50     &lt;span class="c"&gt;# Debian, Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; sshd &lt;span class="nt"&gt;-n&lt;/span&gt; 50    &lt;span class="c"&gt;# RHEL, Fedora, Alma, Rocky&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Systems running rsyslog also write to &lt;code&gt;/var/log/auth.log&lt;/code&gt; (Debian family) or &lt;code&gt;/var/log/secure&lt;/code&gt; (RHEL family). If the box only accepts keys and yours doesn't work, you need console access or another account to read this; that's worth knowing before you lock yourself out of something.&lt;/p&gt;

&lt;p&gt;These are the lines our lab produced, one per cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed publickey for root from 127.0.0.1 port 37630 ssh2: ED25519 SHA256:R/2QZex7...
Invalid user nosuchuser from 127.0.0.1 port 37646
Authentication refused: bad ownership or modes for file /.../authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wrong user
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Invalid user&lt;/code&gt; means the account doesn't exist on that machine. The client still prints exactly the same &lt;code&gt;Permission denied (publickey).&lt;/code&gt;, which is why this one wastes so much time. Cloud images are the usual trap: per the AWS docs, Amazon Linux and RHEL images use &lt;code&gt;ec2-user&lt;/code&gt;, Ubuntu uses &lt;code&gt;ubuntu&lt;/code&gt;, Debian uses &lt;code&gt;admin&lt;/code&gt;. Logging into an Ubuntu instance as &lt;code&gt;ec2-user&lt;/code&gt; fails with a perfectly good key. (If an EC2 connection hangs instead of being denied, that's a network problem; see &lt;a href="https://termique.app/blog/debug-ssh-connection-timeout-aws-ec2/" rel="noopener noreferrer"&gt;debugging SSH timeouts on EC2&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Set the user per host in &lt;code&gt;~/.ssh/config&lt;/code&gt; so you don't have to remember it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; web-prod
    &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.0.113.10
    &lt;span class="k"&gt;User&lt;/span&gt; ubuntu
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/web-prod_ed25519
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; tells ssh to use only the configured identity files, even if your agent holds more keys. That keeps the offer list short and predictable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrong key
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Failed publickey ... ED25519 SHA256:...&lt;/code&gt; means the server read the file and the offered key isn't in it. Compare fingerprints. On the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-lf&lt;/span&gt; ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints one fingerprint per key line. On your machine, &lt;code&gt;ssh-keygen -lf ~/.ssh/web-prod_ed25519.pub&lt;/code&gt; prints the fingerprint of your public key. If you only have the private key, &lt;code&gt;ssh-keygen -y -f ~/.ssh/web-prod_ed25519&lt;/code&gt; prints its public half, which you can paste into &lt;code&gt;authorized_keys&lt;/code&gt; (one key per line, no line breaks inside the key).&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrong file
&lt;/h3&gt;

&lt;p&gt;sshd doesn't always read &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;. The location comes from &lt;code&gt;AuthorizedKeysFile&lt;/code&gt;, which can point elsewhere, list several files, or be overridden in a &lt;code&gt;Match&lt;/code&gt; block. Ask sshd for its effective config instead of reading the file by eye:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(authorizedkeysfile|pubkeyauthentication|strictmodes) '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sshd -T&lt;/code&gt; is extended test mode: it validates the config and prints every setting as sshd will apply it.&lt;/p&gt;

&lt;p&gt;Windows Server and Windows 10/11 OpenSSH have their own twist. For accounts in the Administrators group, Microsoft's docs say the key goes in &lt;code&gt;C:\ProgramData\ssh\administrators_authorized_keys&lt;/code&gt;, not the file in your user profile, and that file's ACL must allow only Administrators and SYSTEM. Adding your key to &lt;code&gt;C:\Users\you\.ssh\authorized_keys&lt;/code&gt; on an admin account is the classic "permission denied publickey windows" cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does sshd reject a key that's in authorized_keys?
&lt;/h2&gt;

&lt;p&gt;This is the &lt;code&gt;Authentication refused: bad ownership or modes&lt;/code&gt; line. With &lt;code&gt;StrictModes yes&lt;/code&gt;, which is the default, sshd checks the ownership and modes of the user's files and home directory before accepting a login, and refuses if anything is writable by other users. In our lab, &lt;code&gt;chmod 666 authorized_keys&lt;/code&gt; was enough: the key in the file was correct, the client printed &lt;code&gt;Permission denied (publickey).&lt;/code&gt;, and only the server log said why.&lt;/p&gt;

&lt;p&gt;The fix is the standard set of modes, run as the account owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;go-w ~
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check ownership with &lt;code&gt;ls -ld ~ ~/.ssh ~/.ssh/authorized_keys&lt;/code&gt;. The files should belong to the account you log in as; if you created them with &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;sudo chown -R deploy:deploy /home/deploy/.ssh&lt;/code&gt; puts them back.&lt;/p&gt;

&lt;p&gt;Don't fix this by setting &lt;code&gt;StrictModes no&lt;/code&gt;. It hides the symptom and leaves a writable file that decides who can log in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if ssh won't use your private key?
&lt;/h2&gt;

&lt;p&gt;Sometimes the client is the one saying no. Two cases from the lab.&lt;/p&gt;

&lt;p&gt;The private key is readable by other users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@         WARNING: UNPROTECTED PRIVATE KEY FILE!          @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for 'goodloose' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Load key "goodloose": bad permissions
root@127.0.0.1: Permission denied (publickey).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chmod 600&lt;/code&gt; on the private key fixes it. This happens a lot after copying keys from a USB stick, a Windows share, or a zip. One detail worth knowing: in this test the server log showed &lt;code&gt;Accepted key ... found at .../authorized_keys:1&lt;/code&gt; and then &lt;code&gt;Connection closed&lt;/code&gt;. The client offered the matching &lt;code&gt;.pub&lt;/code&gt; file, the server said that key is fine, and then the client couldn't produce a signature because it refused to load the private half. A server log that says "Accepted key" next to a failed login points at the client.&lt;/p&gt;

&lt;p&gt;The key file doesn't exist at the path you gave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: Identity file doesnotexist not accessible: No such file or directory.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ssh warns and carries on with whatever other keys it can find, so the final error is the same. A typo in an &lt;code&gt;IdentityFile&lt;/code&gt; path, or a key that only exists on your other laptop, is the usual suspect.&lt;/p&gt;

&lt;p&gt;If your key has a passphrase and the prompt keeps rejecting it, that's a different failure with its own error text; see &lt;a href="https://termique.app/blog/ssh-key-passphrase-not-working/" rel="noopener noreferrer"&gt;why your SSH key passphrase won't authenticate&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does an old RSA key suddenly fail?
&lt;/h2&gt;

&lt;p&gt;OpenSSH 8.8 (September 2021) disabled RSA signatures using SHA-1, the &lt;code&gt;ssh-rsa&lt;/code&gt; signature algorithm, by default. The key format didn't change: an existing RSA key still works with &lt;code&gt;rsa-sha2-256&lt;/code&gt; and &lt;code&gt;rsa-sha2-512&lt;/code&gt;, which OpenSSH has supported since 7.2. Trouble starts when one side only speaks the old algorithm, usually an old appliance, an old embedded sshd, or a client config that pinned &lt;code&gt;ssh-rsa&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the lab we forced the client to sign only with SHA-1 against a 9.6 server, and &lt;code&gt;ssh -v&lt;/code&gt; showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug1: Skipping ssh-rsa key rsa - corresponding algorithm not supported by server
debug1: No more authentication methods to try.
root@127.0.0.1: Permission denied (publickey).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's missing: no &lt;code&gt;Offering public key&lt;/code&gt; line. The client skipped the key before sending it, so the server log only shows &lt;code&gt;Connection closed by authenticating user&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a legacy server you can't upgrade, the OpenSSH release notes give a per-host stopgap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; old-host
    &lt;span class="k"&gt;HostkeyAlgorithms&lt;/span&gt; +ssh-rsa
    &lt;span class="k"&gt;PubkeyAcceptedAlgorithms&lt;/span&gt; +ssh-rsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scope it to that one &lt;code&gt;Host&lt;/code&gt;, never &lt;code&gt;Host *&lt;/code&gt;. The real fix is a new Ed25519 key; &lt;a href="https://termique.app/blog/ssh-keygen-best-practices-2026/" rel="noopener noreferrer"&gt;our ssh-keygen guide&lt;/a&gt; covers generating one properly. If the RSA failure is on your own client, search your config for a pinned &lt;code&gt;PubkeyAcceptedAlgorithms ssh-rsa&lt;/code&gt; (without the &lt;code&gt;+&lt;/code&gt;), which replaces the whole default list.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it for GitHub, GitLab and Bitbucket?
&lt;/h2&gt;

&lt;p&gt;Git hosts produce the same error with a few extra causes. Per GitHub's troubleshooting docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always connect as &lt;code&gt;git&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;ssh -T your-username@github.com&lt;/code&gt; fails with &lt;code&gt;Permission denied (publickey).&lt;/code&gt; The remote URL must be &lt;code&gt;git@github.com:owner/repo.git&lt;/code&gt;. GitLab (&lt;code&gt;git@gitlab.com&lt;/code&gt;) and Bitbucket (&lt;code&gt;git@bitbucket.org&lt;/code&gt;) use the same &lt;code&gt;git&lt;/code&gt; user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the connection directly&lt;/strong&gt;: &lt;code&gt;ssh -T git@github.com&lt;/code&gt; should greet you by username. Add &lt;code&gt;-v&lt;/code&gt; to see which key was offered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't mix &lt;code&gt;sudo&lt;/code&gt; and git.&lt;/strong&gt; &lt;code&gt;sudo git push&lt;/code&gt; runs as root and uses root's keys, not the ones you generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the key is loaded&lt;/strong&gt;: &lt;code&gt;ssh-add -l -E sha256&lt;/code&gt; lists agent keys with SHA-256 fingerprints, and you compare them with the list under your account's SSH key settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have one key for work and one for personal repos, the host picks whichever key it sees first and that may belong to the other account. A host alias with its own &lt;code&gt;IdentityFile&lt;/code&gt; and &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; fixes that; our post on &lt;a href="https://termique.app/blog/managing-multiple-ssh-keys-across-devices/" rel="noopener noreferrer"&gt;managing multiple SSH keys across devices&lt;/a&gt; has the full config.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist to run top to bottom
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;ssh -v&lt;/code&gt; and find the &lt;code&gt;Offering public key&lt;/code&gt; lines. No offer means a client problem; offer then refusal means a server problem.&lt;/li&gt;
&lt;li&gt;Confirm the username. Read the server log for &lt;code&gt;Invalid user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compare the offered fingerprint with &lt;code&gt;ssh-keygen -lf ~/.ssh/authorized_keys&lt;/code&gt; on the server.&lt;/li&gt;
&lt;li&gt;Ask sshd where it reads keys: &lt;code&gt;sudo sshd -T | grep -i authorizedkeysfile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fix modes: home not group/world-writable, &lt;code&gt;~/.ssh&lt;/code&gt; 700, &lt;code&gt;authorized_keys&lt;/code&gt; 600, all owned by the user.&lt;/li&gt;
&lt;li&gt;Fix the private key: &lt;code&gt;chmod 600&lt;/code&gt;, correct path in &lt;code&gt;IdentityFile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On Windows admin accounts, use &lt;code&gt;C:\ProgramData\ssh\administrators_authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For an old RSA setup, look for a &lt;code&gt;Skipping ssh-rsa key&lt;/code&gt; line and prefer a new Ed25519 key over re-enabling SHA-1.&lt;/li&gt;
&lt;li&gt;For Git hosts, connect as &lt;code&gt;git&lt;/code&gt; and test with &lt;code&gt;ssh -T&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where does termique fit?
&lt;/h2&gt;

&lt;p&gt;Most of this guide is about one question: which key is going to which host as which user. termique makes that explicit per host. Each saved host has its own username and auth type, and with key auth you pick the key from a dropdown, so there's no default key list being tried behind your back.&lt;/p&gt;

&lt;p&gt;The SSH keys page shows each key's SHA-256 fingerprint, the same format &lt;code&gt;ssh-keygen -lf ~/.ssh/authorized_keys&lt;/code&gt; prints on the server, so the "wrong key" check is a visual comparison. Keys can be generated (Ed25519 recommended, or RSA) or imported in OpenSSH or PEM format, and the private keys sync across your devices as end-to-end encrypted ciphertext. If a connection still fails, the built-in AI assistant can be asked about the error in the session.&lt;/p&gt;

&lt;p&gt;termique is a free, native ssh manager we build, with end-to-end encrypted credentials. The free plan covers 3 hosts. It won't fix file modes on the server for you; that part is still &lt;code&gt;chmod&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why do I get Permission denied (publickey) when my key is in authorized_keys?
&lt;/h3&gt;

&lt;p&gt;Usually because sshd refuses to read the file. Check the server log for &lt;code&gt;Authentication refused: bad ownership or modes&lt;/code&gt;, then set &lt;code&gt;~/.ssh&lt;/code&gt; to 700, &lt;code&gt;authorized_keys&lt;/code&gt; to 600, and make sure the home directory isn't group or world writable. The other common cause is logging in as a different user than the one whose &lt;code&gt;authorized_keys&lt;/code&gt; you edited.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix Permission denied (publickey) on GitHub?
&lt;/h3&gt;

&lt;p&gt;Connect as &lt;code&gt;git&lt;/code&gt;, not your username: &lt;code&gt;ssh -T git@github.com&lt;/code&gt;. Make sure your key is loaded (&lt;code&gt;ssh-add -l -E sha256&lt;/code&gt;) and listed in your GitHub SSH key settings, and don't run git with &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "Permission denied (publickey,password)" mean?
&lt;/h3&gt;

&lt;p&gt;Same failure, but the server also accepts passwords. Your keys were rejected and password login also failed or wasn't attempted. Read &lt;code&gt;ssh -v&lt;/code&gt; the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Permission denied (publickey) happen on AWS EC2?
&lt;/h3&gt;

&lt;p&gt;Most often the username doesn't match the image: &lt;code&gt;ec2-user&lt;/code&gt; for Amazon Linux, &lt;code&gt;ubuntu&lt;/code&gt; for Ubuntu, &lt;code&gt;admin&lt;/code&gt; for Debian. Also check you're using the key pair the instance was launched with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to set StrictModes no to fix this?
&lt;/h3&gt;

&lt;p&gt;No. It turns off the check that stops other users from editing the file that controls who can log in. Fix the permissions instead.&lt;/p&gt;

&lt;p&gt;If you're juggling keys and usernames across a lot of servers, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;try termique free&lt;/a&gt;: one host entry per server, its key picked explicitly, its fingerprint on screen.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>termique v0.9.0 - Agents that work on your servers</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Wed, 04 Nov 2026 16:47:52 +0000</pubDate>
      <link>https://dev.to/masitings/termique-v090-agents-that-work-on-your-servers-2dib</link>
      <guid>https://dev.to/masitings/termique-v090-agents-that-work-on-your-servers-2dib</guid>
      <description>&lt;p&gt;This is a feature release. The AI assistant in the terminal is gone, and Agents take its place: they work on your hosts over SSH, look into problems on their own and wait for your OK before changing anything.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Update from the app when the prompt appears, or grab a fresh build from the &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;download page&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick the engine.&lt;/strong&gt; Choose termique AI, or Claude Code, Codex or OpenCode already on your computer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right next to your session.&lt;/strong&gt; Agents sit next to Terminal and Files in each session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On every plan.&lt;/strong&gt; termique AI gives 15 prompts a day on Free and 100 on Pro, and shows how many are left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group rules.&lt;/strong&gt; Allowed, Ask or Never for each kind of action, plus rules of your own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sudo and updates always ask.&lt;/strong&gt; sudo and system updates always wait for an OK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approve from anywhere.&lt;/strong&gt; Approve from the Approvals page or your phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host reports.&lt;/strong&gt; Generate a report on a host.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Every agent follows the same rules and asks for the same approvals, whichever engine does the work, and everything it runs lands in the audit log.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant answered questions about the terminal in front of you. Agents do the legwork on the host itself: they read the logs, metrics and files, follow the trail, and come back with what they found. When the next step changes something, it stops and waits for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teams
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your rules travel with the host.&lt;/strong&gt; People you share a host with follow your rules in the terminal and Files too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One line, not a pop-up.&lt;/strong&gt; A command that needs your OK waits in one line at the bottom of their terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust for a while.&lt;/strong&gt; Approve and trust someone for a while, or until their session ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clear record.&lt;/strong&gt; See what ran, who approved it and what was blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rules apply to the people you share with, not only to Agents. A teammate typing in the terminal or working in Files on your host meets the same Allowed, Ask and Never decisions an agent does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Agents can't get around a blocked command by spelling it differently.&lt;/li&gt;
&lt;li&gt;Claude Code and Codex only get termique's tools on your hosts.&lt;/li&gt;
&lt;li&gt;An approval runs its command once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bug fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A command pasted right after the prompt is read correctly.&lt;/li&gt;
&lt;li&gt;Approval cards are no longer see-through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Update from the app to get Agents, and tell us what you want them to do next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/termique-v0-7-0/" rel="noopener noreferrer"&gt;termique v0.7.0: redesigned workspace, safer defaults&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/ai-coding-agents-ssh-command-approval/" rel="noopener noreferrer"&gt;AI coding agents over SSH: who approves the commands they run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/per-command-audit-logs-shared-ssh-access/" rel="noopener noreferrer"&gt;What a per-command audit log actually catches&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>SSH Keys on macOS with Touch ID: Secure Enclave Setup (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Wed, 04 Nov 2026 03:20:57 +0000</pubDate>
      <link>https://dev.to/masitings/ssh-keys-on-macos-with-touch-id-secure-enclave-setup-2026-1c6a</link>
      <guid>https://dev.to/masitings/ssh-keys-on-macos-with-touch-id-secure-enclave-setup-2026-1c6a</guid>
      <description>&lt;p&gt;A normal SSH key is a file. Anything that runs as you can read &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt;, and if the key has no passphrase, a copy of that file is a copy of your access. A Touch ID SSH key works differently: the private key is generated inside your Mac's Secure Enclave, it never leaves it, and every signature needs your fingerprint. Recent macOS versions can do this with two built-in tools, &lt;code&gt;sc_auth&lt;/code&gt; and &lt;code&gt;/usr/lib/ssh-keychain.dylib&lt;/code&gt;, without installing anything.&lt;/p&gt;

&lt;p&gt;This guide covers the setup, how to make it survive a reboot, how to use it with GitHub and your own servers, and the part most write-ups skip: what you give up when a key can't be copied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What a Secure Enclave SSH key is and which Macs have one.&lt;/li&gt;
&lt;li&gt;The three commands that create a Touch ID SSH key and load it into ssh-agent.&lt;/li&gt;
&lt;li&gt;How to make it work after a reboot with &lt;code&gt;~/.ssh/config&lt;/code&gt; or &lt;code&gt;SSH_SK_PROVIDER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;How it compares with Secretive and a YubiKey.&lt;/li&gt;
&lt;li&gt;The tradeoffs: one key per Mac, no backup, and servers that need OpenSSH 8.2 or later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you use Touch ID for an SSH key on macOS?
&lt;/h2&gt;

&lt;p&gt;You create a non-exportable P-256 identity in the Secure Enclave with &lt;code&gt;sc_auth&lt;/code&gt;, then tell OpenSSH to talk to it through Apple's security key provider, &lt;code&gt;/usr/lib/ssh-keychain.dylib&lt;/code&gt;. OpenSSH treats the Secure Enclave like a FIDO security key, so the public key shows up as &lt;code&gt;sk-ecdsa-sha2-nistp256@openssh.com&lt;/code&gt;, the same type a hardware key would give you.&lt;/p&gt;

&lt;p&gt;The short version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create the identity (Touch ID prompt). Run once per Mac.&lt;/span&gt;
sc_auth create-ctk-identity &lt;span class="nt"&gt;-l&lt;/span&gt; ssh-key &lt;span class="nt"&gt;-k&lt;/span&gt; p-256-ne &lt;span class="nt"&gt;-t&lt;/span&gt; bio

&lt;span class="c"&gt;# 2. Load it into ssh-agent. Press Enter at the PIN prompt.&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; /usr/lib/ssh-keychain.dylib

&lt;span class="c"&gt;# 3. Print the public key to paste into GitHub or authorized_keys.&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-L&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;git&lt;/code&gt; over SSH trigger a Touch ID prompt whenever the key signs a login. The rest of this article explains each piece and the setup that doesn't need step 2 after every reboot.&lt;/p&gt;

&lt;p&gt;A note on testing: we build termique on Linux and couldn't run the &lt;code&gt;sc_auth&lt;/code&gt; steps on a Mac for this article. The macOS commands and outputs come from two public write-ups (linked in the sources) and Apple's man pages they cite. The OpenSSH side (flags, key type, server support) is checked against the OpenSSH manuals and tested on OpenSSH 9.6.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Secure Enclave SSH key?
&lt;/h2&gt;

&lt;p&gt;The Secure Enclave is a separate security subsystem inside Apple's chips. It has its own processor and protected memory, isolated from the main CPU, and it can generate and use keys that the operating system never sees in plain form. Apple lists it on all Macs with Apple silicon, Intel Macs with the T2 Security Chip, and the 2016 and 2017 MacBook Pro models with the T1 chip.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sc_auth&lt;/code&gt; is the macOS command for smart card and CryptoTokenKit identities. With the key type &lt;code&gt;p-256-ne&lt;/code&gt;, it asks the Secure Enclave to generate a P-256 key pair where the private half is non-exportable (that's what &lt;code&gt;-ne&lt;/code&gt; means). Signing happens inside the enclave. Your Mac asks for Touch ID, the enclave signs the login challenge, and only the signature comes out.&lt;/p&gt;

&lt;p&gt;That gives you three things a key file can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No file to steal.&lt;/strong&gt; There's no private key on disk to copy, back up by accident, or leak in a dotfiles repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prompt per use.&lt;/strong&gt; With &lt;code&gt;-t bio&lt;/code&gt;, every signature needs a fingerprint, so malware that reaches your ssh-agent can't silently log in somewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing to forget to encrypt.&lt;/strong&gt; There's no passphrase to skip, because there's no key file to protect with one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not protect you from everything. If you approve a Touch ID prompt for a login you didn't start, that login goes through. And it doesn't stop someone who can get your enrolled finger onto the sensor, same as any other Touch ID action.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you create the identity with sc_auth?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sc_auth create-ctk-identity -l ssh-key -k p-256-ne -t bio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-l ssh-key&lt;/code&gt; is a free-form label so you can recognise it later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-k p-256-ne&lt;/code&gt; is the key type. The write-ups report &lt;code&gt;p-256&lt;/code&gt;, &lt;code&gt;p-384&lt;/code&gt;, &lt;code&gt;p-521&lt;/code&gt;, &lt;code&gt;p-256-ne&lt;/code&gt; and &lt;code&gt;p-384-ne&lt;/code&gt; as accepted values. Use &lt;code&gt;p-256-ne&lt;/code&gt;: OpenSSH's security key types are &lt;code&gt;ecdsa-sk&lt;/code&gt; (P-256) and &lt;code&gt;ed25519-sk&lt;/code&gt;, so P-384 won't work for SSH, and the variants without &lt;code&gt;-ne&lt;/code&gt; are exportable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-t bio&lt;/code&gt; requires biometrics for each use. &lt;code&gt;-t none&lt;/code&gt; still keeps the key inside the enclave but lets programs use it without a prompt. Pick &lt;code&gt;none&lt;/code&gt; only if you'd rather trade the per-use check for fewer interruptions (frequent &lt;code&gt;git fetch&lt;/code&gt; can get tiring).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check that it exists, and see its SSH fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sc_auth list-ctk-identities
sc_auth list-ctk-identities &lt;span class="nt"&gt;-t&lt;/span&gt; ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second form prints the key hash as an SSH-style &lt;code&gt;SHA256:&lt;/code&gt; fingerprint, which is what you'll compare against the server side later. To remove an identity, use its hash from the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sc_auth delete-ctk-identity &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;public-key-hash&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;man sc_auth&lt;/code&gt; on your Mac before you start. If &lt;code&gt;create-ctk-identity&lt;/code&gt; isn't in it, your macOS version doesn't have this feature. The original write-up describes it on macOS Tahoe, and later reports say it works on Sequoia too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you load the Touch ID key into ssh-agent?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; /usr/lib/ssh-keychain.dylib
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the OpenSSH manual, &lt;code&gt;-K&lt;/code&gt; means "load resident keys from a FIDO authenticator" and &lt;code&gt;-S&lt;/code&gt; sets the provider library to use instead of the built-in USB support. Here the provider is Apple's &lt;code&gt;ssh-keychain.dylib&lt;/code&gt;, which answers on behalf of the Secure Enclave. ssh-add will ask for a PIN because it assumes a USB security key; press Enter. You should see &lt;code&gt;Resident identity added: ECDSA-SK SHA256:...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've used macOS for a while, &lt;code&gt;ssh-add -K&lt;/code&gt; may look familiar for a different reason. Older Apple guides used it to store a key's passphrase in the macOS Keychain. Apple moved that behaviour to &lt;code&gt;--apple-use-keychain&lt;/code&gt;. In this article, &lt;code&gt;-K&lt;/code&gt; is the upstream OpenSSH flag for resident keys.&lt;/p&gt;

&lt;p&gt;Two details that save confusion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent remembers which provider loaded each key, so plain &lt;code&gt;ssh host&lt;/code&gt; works afterwards without extra options.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-agent&lt;/code&gt; only loads provider libraries from allowed paths, &lt;code&gt;/usr/lib/*&lt;/code&gt; and &lt;code&gt;/usr/local/lib/*&lt;/code&gt; by default. Use the system path as is; don't copy the dylib somewhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ssh-add -L&lt;/code&gt; prints the public key. Use the line that starts with &lt;code&gt;sk-ecdsa-sha2-nistp256@openssh.com&lt;/code&gt;. If the agent also holds other keys, &lt;code&gt;ssh-add -l&lt;/code&gt; lists them with fingerprints so you can tell which is which (more on that in our &lt;a href="https://termique.app/blog/ssh-agent-forwarding-security-risks-explained/" rel="noopener noreferrer"&gt;ssh-agent forwarding&lt;/a&gt; post, which matters here: a forwarded agent hands remote hosts a way to ask for signatures).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make the Touch ID key survive a reboot?
&lt;/h2&gt;

&lt;p&gt;The agent forgets everything when it restarts, so the &lt;code&gt;ssh-add&lt;/code&gt; step comes back after every reboot. There are two ways around that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: write a key handle to disk with ssh-keygen -K
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.ssh
ssh-keygen &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /usr/lib/ssh-keychain.dylib &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh-keygen -K&lt;/code&gt; downloads resident keys and writes the public and private key files to the current directory, which is why you &lt;code&gt;cd ~/.ssh&lt;/code&gt; first. You get &lt;code&gt;id_ecdsa_sk_rk&lt;/code&gt; and &lt;code&gt;id_ecdsa_sk_rk.pub&lt;/code&gt;. The "private" file is only a reference to the credential in the Secure Enclave, not key material, which is why &lt;code&gt;-N ""&lt;/code&gt; (no passphrase) is fine here. Copying it to another Mac gives that Mac nothing it can sign with.&lt;/p&gt;

&lt;p&gt;Then point ssh at it in &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; *
    &lt;span class="k"&gt;SecurityKeyProvider&lt;/span&gt; /usr/lib/ssh-keychain.dylib
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ecdsa_sk_rk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SecurityKeyProvider&lt;/code&gt; is a standard &lt;code&gt;ssh_config&lt;/code&gt; option: the library ssh uses for FIDO-style keys instead of its built-in USB support. If you already keep a large config, put this in a narrower &lt;code&gt;Host&lt;/code&gt; block for the servers that should use the Touch ID key (our &lt;a href="https://termique.app/blog/ssh-config-file-organization-tips-devops/" rel="noopener noreferrer"&gt;ssh config organization&lt;/a&gt; post covers splitting it up).&lt;/p&gt;

&lt;p&gt;If you created several identities with &lt;code&gt;sc_auth&lt;/code&gt;, check that &lt;code&gt;ssh-keygen -K&lt;/code&gt; wrote a file for each of them. One public report describes an older build that saved only one, fixed in a later macOS beta.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: set SSH_SK_PROVIDER for your shell
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in ~/.zprofile&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSH_SK_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/lib/ssh-keychain.dylib
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;ssh-add&lt;/code&gt; and &lt;code&gt;ssh-keygen&lt;/code&gt; all read &lt;code&gt;SSH_SK_PROVIDER&lt;/code&gt;, so after this, &lt;code&gt;ssh-add -K&lt;/code&gt; and &lt;code&gt;ssh-keygen -K&lt;/code&gt; work without the long path. It only applies to programs started from that shell, though. GUI apps that run ssh on their own won't see it, which is one reason to prefer the config file.&lt;/p&gt;

&lt;p&gt;To label the key so you can tell it apart in lists later, change the comment on the handle file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"macbook-air secure enclave"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ecdsa_sk_rk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does a Secure Enclave key work with GitHub and your servers?
&lt;/h2&gt;

&lt;p&gt;Yes, as long as the other end accepts security key types.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; accepts &lt;code&gt;sk-ecdsa-sha2-nistp256@openssh.com&lt;/code&gt; keys. Paste the &lt;code&gt;ssh-add -L&lt;/code&gt; line into your SSH keys settings and test with &lt;code&gt;ssh -T git@github.com&lt;/code&gt;. You'll get the Touch ID prompt before the greeting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab&lt;/strong&gt; lists &lt;code&gt;ECDSA_SK&lt;/code&gt; as supported and notes it needs OpenSSH 8.2 or later on both the client and the GitLab server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own Linux servers&lt;/strong&gt;: OpenSSH's sshd accepts &lt;code&gt;sk-ecdsa-sha2-nistp256@openssh.com&lt;/code&gt; by default. On our OpenSSH 9.6 test box, &lt;code&gt;sshd -T&lt;/code&gt; lists it in &lt;code&gt;pubkeyacceptedalgorithms&lt;/code&gt; with no config change. Add the public key line to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; as usual.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To check you pasted the right key, compare fingerprints. On the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-lf&lt;/span&gt; ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Secure Enclave key shows up as &lt;code&gt;256 SHA256:... (ECDSA-SK)&lt;/code&gt;. That fingerprint should match the one from &lt;code&gt;ssh-add -l&lt;/code&gt; or &lt;code&gt;sc_auth list-ctk-identities -t ssh&lt;/code&gt; on your Mac.&lt;/p&gt;

&lt;p&gt;Where it can fail: servers running OpenSSH older than 8.2, network appliances with their own SSH implementation, and hardened environments with a fixed list of approved algorithms that leaves out the &lt;code&gt;sk-&lt;/code&gt; types. Check before you make it your only key for a box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure Enclave via ssh-keychain.dylib vs Secretive vs a YubiKey
&lt;/h2&gt;

&lt;p&gt;These solve the same problem in different ways, and each has a real strength.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Built-in (sc_auth + ssh-keychain.dylib)&lt;/strong&gt;: nothing to install, uses the standard OpenSSH agent and config. The downside is documentation: the provider interface isn't documented by Apple in any detail, and the original write-up notes there's no attestation to prove a key really lives in the enclave.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secretive&lt;/strong&gt; is an open-source macOS app built for exactly this: keys in the Secure Enclave, Touch ID or Apple Watch approval, and a notification each time a key is used. If you want a UI and access notifications, it's the more polished option, and it has been around longer than the built-in route. Install with &lt;code&gt;brew install secretive&lt;/code&gt;. Its README is blunt about the same limit: keys can't be backed up or moved to a new Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A YubiKey or other FIDO2 key&lt;/strong&gt; (&lt;code&gt;ssh-keygen -t ed25519-sk&lt;/code&gt;, see our &lt;a href="https://termique.app/blog/ssh-keygen-best-practices-2026/" rel="noopener noreferrer"&gt;ssh-keygen best practices&lt;/a&gt;) is portable. One key works on your Mac, a Linux desktop and a work laptop, and survives your Mac dying. It costs money, and you have to carry it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common pattern is a hardware key as the portable or break-glass key, and a Secure Enclave key for day-to-day use on the laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the tradeoffs of a non-exportable SSH key?
&lt;/h2&gt;

&lt;p&gt;The thing that makes this key safe is also what makes it awkward: it exists on one Mac and nowhere else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One key per Mac.&lt;/strong&gt; A new Mac means a new identity, and re-registering it on GitHub and every server. That's the &lt;a href="https://termique.app/blog/managing-multiple-ssh-keys-across-devices/" rel="noopener noreferrer"&gt;one key per device&lt;/a&gt; model enforced by hardware, which is good practice, but you need a list of where each key is authorized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backup.&lt;/strong&gt; If the Mac dies, is wiped, or the logic board is replaced, the key is gone. Before you remove other keys from a server, make sure you have a second way in: another key, a hardware key, or console access from your provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exportable variants exist, with caveats.&lt;/strong&gt; &lt;code&gt;sc_auth&lt;/code&gt; can create exportable keys (&lt;code&gt;p-256&lt;/code&gt; without &lt;code&gt;-ne&lt;/code&gt;) protected by the enclave, and &lt;code&gt;export-ctk-identity&lt;/code&gt; can write them out. Public reports on moving them between Macs are mixed, and an exportable key gives up the main guarantee. If you want portability, a hardware key is the cleaner answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts add up.&lt;/strong&gt; With &lt;code&gt;-t bio&lt;/code&gt;, every signature asks for Touch ID. Scripts and cron jobs that ssh as you will stop and wait. Use a separate, narrowly authorized key for automation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac only.&lt;/strong&gt; It doesn't help on your phone or a Linux box, and apps that don't support security key types can't use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation is still your job.&lt;/strong&gt; If the Mac is lost, remove its public key from every &lt;code&gt;authorized_keys&lt;/code&gt; and Git host. Our &lt;a href="https://termique.app/blog/ssh-key-rotation-best-practices/" rel="noopener noreferrer"&gt;key rotation&lt;/a&gt; post has a process for that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where does termique fit with a Touch ID SSH key?
&lt;/h2&gt;

&lt;p&gt;It's a different tradeoff, and you may want both.&lt;/p&gt;

&lt;p&gt;A Secure Enclave key is bound to one Mac on purpose. termique goes the other way for the keys you keep in it: you generate an &lt;code&gt;ed25519&lt;/code&gt; or &lt;code&gt;rsa&lt;/code&gt; key, or import an existing OpenSSH or PEM private key, and the private key is encrypted on your device before it syncs, so the server only stores ciphertext. That's how the same key reaches your other devices, including iOS. termique's SSH keys docs list ed25519 and RSA generation and OpenSSH/PEM import; they don't mention security key (&lt;code&gt;sk-&lt;/code&gt;) types, so treat a Touch ID key as something you use from Terminal and plain &lt;code&gt;ssh&lt;/code&gt;, not from termique.&lt;/p&gt;

&lt;p&gt;In practice that can look like this: a Touch ID key on your laptop for the access that matters most, like pushing to production repos or a bastion, and a synced key in termique for the everyday servers you reach from more than one device. termique also shows each key's SHA-256 fingerprint in Settings, so you can line it up with &lt;code&gt;ssh-keygen -lf&lt;/code&gt; on the server the same way. Be precise about what's encrypted: credentials and private keys are end-to-end encrypted, while host names, groups and snippets sync as plain metadata. The details are on our &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use Touch ID for SSH on a Mac without installing anything?
&lt;/h3&gt;

&lt;p&gt;On recent macOS versions, yes. &lt;code&gt;sc_auth create-ctk-identity&lt;/code&gt; creates the key in the Secure Enclave and &lt;code&gt;/usr/lib/ssh-keychain.dylib&lt;/code&gt; lets the built-in OpenSSH use it. Check &lt;code&gt;man sc_auth&lt;/code&gt; for &lt;code&gt;create-ctk-identity&lt;/code&gt; to confirm your version has it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I export or back up a Secure Enclave SSH key?
&lt;/h3&gt;

&lt;p&gt;Not a &lt;code&gt;p-256-ne&lt;/code&gt; key. It's generated inside the enclave and can't leave it. Plan for that with a second key or a hardware key that can still get you in if the Mac is lost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does ssh-add ask for a PIN?
&lt;/h3&gt;

&lt;p&gt;ssh-add assumes a USB security key, which may have a PIN. The Secure Enclave uses Touch ID instead, so press Enter at the prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a Secure Enclave key an Ed25519 key?
&lt;/h3&gt;

&lt;p&gt;No. It's ECDSA on the P-256 curve, shown as &lt;code&gt;sk-ecdsa-sha2-nistp256@openssh.com&lt;/code&gt;. It's widely supported, but older servers that predate OpenSSH 8.2 won't accept it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to run ssh-add after every reboot?
&lt;/h3&gt;

&lt;p&gt;Only if you load the key through the agent. Write a key handle with &lt;code&gt;ssh-keygen -K&lt;/code&gt; and add &lt;code&gt;SecurityKeyProvider&lt;/code&gt; and &lt;code&gt;IdentityFile&lt;/code&gt; to &lt;code&gt;~/.ssh/config&lt;/code&gt;, and ssh picks it up on its own.&lt;/p&gt;

&lt;p&gt;If you manage keys for more servers than you can keep in your head, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;try termique free&lt;/a&gt;: keys generated or imported once, encrypted before they sync, with fingerprints you can check against the server.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>SSH Port Forwarding: -L, -R, -D Examples and Config (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 03:20:36 +0000</pubDate>
      <link>https://dev.to/masitings/ssh-port-forwarding-l-r-d-examples-and-config-2026-4pak</link>
      <guid>https://dev.to/masitings/ssh-port-forwarding-l-r-d-examples-and-config-2026-4pak</guid>
      <description>&lt;p&gt;Your database listens on &lt;code&gt;localhost:5432&lt;/code&gt; on a server, which is exactly how it should be. Now you need to open it in a GUI client on your laptop, and the options look bad: open port 5432 in the firewall, install a VPN for one query, or copy a dump around. SSH port forwarding is the fourth option, and it's usually the right one. You already have an SSH connection to the server; port forwarding carries other TCP traffic through it.&lt;/p&gt;

&lt;p&gt;This guide covers the three kinds of SSH port forwarding (local &lt;code&gt;-L&lt;/code&gt;, remote &lt;code&gt;-R&lt;/code&gt;, dynamic &lt;code&gt;-D&lt;/code&gt;), how to keep tunnels in &lt;code&gt;~/.ssh/config&lt;/code&gt; so you stop retyping them, how to combine them with a jump host, and the failure modes that make a tunnel silently not work. Every command and error message below was run on OpenSSH 9.6 against a throwaway &lt;code&gt;sshd&lt;/code&gt; on localhost while writing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What SSH port forwarding is, and which of &lt;code&gt;-L&lt;/code&gt;, &lt;code&gt;-R&lt;/code&gt; and &lt;code&gt;-D&lt;/code&gt; you need.&lt;/li&gt;
&lt;li&gt;A local forward to reach a database or admin panel that only listens on the server.&lt;/li&gt;
&lt;li&gt;A remote forward to expose a port from your machine on the server, and why it binds to loopback by default.&lt;/li&gt;
&lt;li&gt;A SOCKS proxy with &lt;code&gt;-D&lt;/code&gt; for reaching many internal hosts at once.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LocalForward&lt;/code&gt;, &lt;code&gt;RemoteForward&lt;/code&gt; and &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; in &lt;code&gt;~/.ssh/config&lt;/code&gt;, including through a jump host.&lt;/li&gt;
&lt;li&gt;The exact messages you see when a port is taken or the server forbids forwarding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is SSH port forwarding?
&lt;/h2&gt;

&lt;p&gt;SSH port forwarding makes one end of an SSH connection listen on a TCP port and send whatever connects to it through the encrypted connection, where the other end opens a new connection to a destination you choose. The application on either side talks plain TCP and doesn't know SSH is involved.&lt;/p&gt;

&lt;p&gt;There are three forms, and the letter tells you where the listening port lives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local forward, &lt;code&gt;-L&lt;/code&gt;&lt;/strong&gt;: the port opens on your machine (the client). Connections go to the server, and the server connects onward. Use it to reach something that only the server can reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote forward, &lt;code&gt;-R&lt;/code&gt;&lt;/strong&gt;: the port opens on the server. Connections come back to your machine, and your machine connects onward. Use it to let the server reach something only you can reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic forward, &lt;code&gt;-D&lt;/code&gt;&lt;/strong&gt;: the port opens on your machine as a SOCKS proxy. Each connection says where it wants to go, and the server connects there. Use it when you need many destinations behind the server, not one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flag everyone pairs with these is &lt;code&gt;-N&lt;/code&gt;: "Do not execute a remote command. This is useful for just forwarding ports." Without it, you also get a shell, which is fine for one-off use but awkward for a tunnel you want to leave running.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I forward a remote port to my local machine with -L?
&lt;/h2&gt;

&lt;p&gt;This is the "ssh port forwarding from remote to local" case: a service listens on the server's loopback interface, and you want it on a port on your laptop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:localhost:5432 deploy@db.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the argument as &lt;code&gt;local_port:destination_host:destination_port&lt;/code&gt;. Your machine listens on 5433. When something connects to it, the connection is carried to &lt;code&gt;db.example.com&lt;/code&gt;, and the server connects to &lt;code&gt;localhost:5432&lt;/code&gt;. In a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; app appdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details catch people out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;localhost&lt;/code&gt; in the middle is resolved on the server, not on your laptop.&lt;/strong&gt; The destination is looked up from the server's side. That's what makes this useful: you can also point it at a host only the server can see, for example a managed database on a private network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:10.0.12.7:5432 deploy@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The local port binds to loopback by default.&lt;/strong&gt; In our test, &lt;code&gt;-L 9000:localhost:8080&lt;/code&gt; produced listeners on &lt;code&gt;127.0.0.1:9000&lt;/code&gt; and &lt;code&gt;[::1]:9000&lt;/code&gt; only, so other machines on your network can't use your tunnel. The man page says the local port is bound according to &lt;code&gt;GatewayPorts&lt;/code&gt;, which defaults to &lt;code&gt;no&lt;/code&gt;. You can override it with a bind address (&lt;code&gt;-L 0.0.0.0:5433:localhost:5432&lt;/code&gt; or &lt;code&gt;-g&lt;/code&gt;), but think twice: anyone who can reach your laptop then reaches the server's database.&lt;/p&gt;

&lt;p&gt;Ports below 1024 on your side need root, so pick something like 5433, 8443 or 9000 instead of 443.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I forward multiple ports at once?
&lt;/h3&gt;

&lt;p&gt;Repeat the flag. One SSH connection can carry as many forwards as you like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:localhost:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 6380:localhost:6379 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 9090:localhost:9090 &lt;span class="se"&gt;\&lt;/span&gt;
  deploy@app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you Postgres, Redis and a Prometheus UI on three local ports over a single authenticated connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does remote port forwarding (-R) work?
&lt;/h2&gt;

&lt;p&gt;A remote forward is the mirror image. The server listens, and connections travel back to your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 8080:localhost:3000 deploy@server.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now anything on the server that connects to port 8080 reaches &lt;code&gt;localhost:3000&lt;/code&gt; on your laptop: a dev server, a webhook receiver you're debugging, a service you want a teammate on the box to try. In our test, a &lt;code&gt;curl&lt;/code&gt; on the server side to the forwarded port returned the page served on the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can't anyone else reach my -R port?
&lt;/h3&gt;

&lt;p&gt;By default, &lt;code&gt;sshd&lt;/code&gt; binds remote forwards to the server's loopback address. The &lt;code&gt;-R&lt;/code&gt; documentation is explicit: "By default, TCP listening sockets on the server will be bound to the loopback interface only," and specifying a remote bind address "will only succeed if the server's &lt;code&gt;GatewayPorts&lt;/code&gt; option is enabled."&lt;/p&gt;

&lt;p&gt;What "will only succeed" looks like in practice is worth knowing. We ran &lt;code&gt;ssh -N -R 0.0.0.0:9201:localhost:8080&lt;/code&gt; against an &lt;code&gt;sshd&lt;/code&gt; with default settings. There was no error and no warning. The listener came up on &lt;code&gt;127.0.0.1:9201&lt;/code&gt; and &lt;code&gt;[::1]:9201&lt;/code&gt; only. If you're wondering why your public URL doesn't work while the tunnel looks healthy, this is the first thing to check.&lt;/p&gt;

&lt;p&gt;To let other hosts connect, the server needs &lt;code&gt;GatewayPorts&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;no&lt;/code&gt; (the default): remote forwards are available to the server itself only.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yes&lt;/code&gt;: remote forwards always bind to the wildcard address, reachable from anywhere the firewall allows.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clientspecified&lt;/code&gt;: the client picks the bind address, so &lt;code&gt;-R 0.0.0.0:9201:...&lt;/code&gt; actually means 0.0.0.0.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;clientspecified&lt;/code&gt; over &lt;code&gt;yes&lt;/code&gt; if you need this at all, so a plain &lt;code&gt;-R 8080:...&lt;/code&gt; stays private. A remote forward on a public interface is a hole into your laptop, so put a reverse proxy with auth in front of it, or keep it on loopback and let a proxy on the server reach it there.&lt;/p&gt;

&lt;p&gt;If you leave out the destination (&lt;code&gt;-R 1080&lt;/code&gt;), ssh acts as a SOCKS proxy for clients on the server side and connects out from your machine. That's niche, but it's in the man page and occasionally handy for giving a locked-down server temporary outbound access through your connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use dynamic port forwarding (-D)?
&lt;/h2&gt;

&lt;p&gt;A local forward maps one port to one destination. When you need a dozen internal dashboards behind the same server, a SOCKS proxy is simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; 1080 deploy@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your machine now runs a SOCKS4/SOCKS5 server on port 1080. Point a browser profile or a tool at it, and each connection is opened from the server's side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--socks5-hostname&lt;/span&gt; localhost:1080 http://grafana.internal:3000/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;--socks5-hostname&lt;/code&gt; rather than &lt;code&gt;--socks5&lt;/code&gt; so the name is resolved on the server too. With &lt;code&gt;--socks5&lt;/code&gt;, curl resolves &lt;code&gt;grafana.internal&lt;/code&gt; locally, which fails for internal-only DNS names. In our test the SOCKS proxy returned the page from a service that only listened on the server's loopback.&lt;/p&gt;

&lt;p&gt;The tradeoff: SOCKS only helps applications that speak SOCKS. Browsers and curl do; most database clients and CLIs don't, so for those a plain &lt;code&gt;-L&lt;/code&gt; is still the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep SSH port forwarding in my ssh config?
&lt;/h2&gt;

&lt;p&gt;Retyping &lt;code&gt;-L&lt;/code&gt; flags gets old fast. &lt;code&gt;~/.ssh/config&lt;/code&gt; has a directive for each kind of forward, and you can give a tunnel its own host alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; db-tunnel
  &lt;span class="k"&gt;HostName&lt;/span&gt; db.example.com
  &lt;span class="k"&gt;User&lt;/span&gt; deploy
  &lt;span class="k"&gt;LocalForward&lt;/span&gt; &lt;span class="m"&gt;5433&lt;/span&gt; localhost:5432
  &lt;span class="k"&gt;LocalForward&lt;/span&gt; &lt;span class="m"&gt;6380&lt;/span&gt; localhost:6379
  &lt;span class="k"&gt;ExitOnForwardFailure&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
  &lt;span class="k"&gt;SessionType&lt;/span&gt; &lt;span class="no"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;ssh db-tunnel&lt;/code&gt; opens both forwards and nothing else. The directives map to the flags like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LocalForward 5433 localhost:5432&lt;/code&gt; is &lt;code&gt;-L 5433:localhost:5432&lt;/code&gt;. Note the space between the listen port and the destination instead of a colon.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RemoteForward 8080 localhost:3000&lt;/code&gt; is &lt;code&gt;-R 8080:localhost:3000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DynamicForward 1080&lt;/code&gt; is &lt;code&gt;-D 1080&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SessionType none&lt;/code&gt; is the config form of &lt;code&gt;-N&lt;/code&gt; ("same as the &lt;code&gt;-N&lt;/code&gt; option").&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt; makes ssh quit if it can't set up a forward. More on why that matters below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can confirm what ssh will actually use with &lt;code&gt;ssh -G&lt;/code&gt;, which prints the resolved config without connecting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-G&lt;/span&gt; db-tunnel | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'localforward|exitonforward|sessiontype'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our test host that printed &lt;code&gt;localforward 5433 [localhost]:5432&lt;/code&gt;, &lt;code&gt;exitonforwardfailure yes&lt;/code&gt; and &lt;code&gt;sessiontype none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Keep a tunnel alias separate from the alias you use for a shell on the same box. That way &lt;code&gt;ssh db&lt;/code&gt; gives you a prompt and &lt;code&gt;ssh db-tunnel&lt;/code&gt; gives you ports, and neither surprises you. If your config is getting long, our guide to &lt;a href="https://termique.app/blog/ssh-config-file-organization-tips-devops/" rel="noopener noreferrer"&gt;organizing your ssh config&lt;/a&gt; covers splitting it with &lt;code&gt;Include&lt;/code&gt; and ordering &lt;code&gt;Host&lt;/code&gt; blocks so the specific ones win.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I run a tunnel in the background?
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;-f&lt;/code&gt;, which sends ssh to the background after authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ExitOnForwardFailure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:localhost:5432 deploy@db.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pairing &lt;code&gt;-f&lt;/code&gt; with &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; is the documented way to make it reliable: with that option set, "a client started with &lt;code&gt;-f&lt;/code&gt; will wait for all remote port forwards to be successfully established before placing itself in the background." In other words, a backgrounded tunnel that couldn't bind its ports exits with an error instead of sitting there doing nothing. In our test, the command above returned 0 and the forwarded port answered right away. Stop the tunnel later with &lt;code&gt;kill&lt;/code&gt; on that ssh process, or use connection multiplexing (next section) so you can control it by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I add a forward to a connection that's already open?
&lt;/h2&gt;

&lt;p&gt;Yes, if you use connection multiplexing. With a control socket (&lt;code&gt;ControlMaster&lt;/code&gt; and &lt;code&gt;ControlPath&lt;/code&gt; in your config), &lt;code&gt;ssh -O&lt;/code&gt; sends commands to the running master connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-O&lt;/span&gt; forward &lt;span class="nt"&gt;-L&lt;/span&gt; 9500:localhost:8080 db-tunnel
ssh &lt;span class="nt"&gt;-O&lt;/span&gt; cancel  &lt;span class="nt"&gt;-L&lt;/span&gt; 9500:localhost:8080 db-tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our test, &lt;code&gt;-O forward&lt;/code&gt; added the port to a live tunnel without re-authenticating, the page was reachable on 9500, and after &lt;code&gt;-O cancel&lt;/code&gt; the same &lt;code&gt;curl&lt;/code&gt; got "connection refused". That's handy when you realize mid-debugging that you need one more port. In an interactive session without multiplexing, the &lt;code&gt;~C&lt;/code&gt; escape opens a command line where you can add a forward too, but current OpenSSH disables it by default: you need &lt;code&gt;EnableEscapeCommandline yes&lt;/code&gt; in your config first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I forward a port through a jump host?
&lt;/h2&gt;

&lt;p&gt;Combine the forward with &lt;code&gt;ProxyJump&lt;/code&gt;. The jump host carries the SSH connection; the forward is set up on the final host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; bastion
  &lt;span class="k"&gt;HostName&lt;/span&gt; bastion.example.com
  &lt;span class="k"&gt;User&lt;/span&gt; deploy

&lt;span class="k"&gt;Host&lt;/span&gt; app-db
  &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.0.12.20
  &lt;span class="k"&gt;User&lt;/span&gt; deploy
  &lt;span class="k"&gt;ProxyJump&lt;/span&gt; bastion
  &lt;span class="k"&gt;LocalForward&lt;/span&gt; &lt;span class="m"&gt;5433&lt;/span&gt; localhost:5432
  &lt;span class="k"&gt;SessionType&lt;/span&gt; &lt;span class="no"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh app-db&lt;/code&gt; now connects through the bastion to the private host and gives you its Postgres on local port 5433. On the command line, the same thing is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-J&lt;/span&gt; bastion &lt;span class="nt"&gt;-L&lt;/span&gt; 5433:localhost:5432 app-db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions worked in our test. One gotcha from the man page: options given on the command line generally apply to the destination, not the jump host, so put the jump host's user, key and port in its own &lt;code&gt;Host&lt;/code&gt; block rather than in &lt;code&gt;-o&lt;/code&gt; flags. Our &lt;a href="https://termique.app/blog/bastion-host-proxyjump-setup/" rel="noopener noreferrer"&gt;bastion host and ProxyJump guide&lt;/a&gt; covers the bastion side in detail.&lt;/p&gt;

&lt;p&gt;There's a second way to do this: forward from the bastion to the private host directly (&lt;code&gt;ssh -N -L 5433:10.0.12.20:5432 bastion&lt;/code&gt;). That only needs SSH access to the bastion, but the last hop from the bastion to the database travels unencrypted inside your network, and the database sees the bastion as the client. &lt;code&gt;ProxyJump&lt;/code&gt; keeps SSH end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is my SSH port forwarding not working?
&lt;/h2&gt;

&lt;p&gt;Most broken tunnels fall into one of these, and the messages are specific enough to tell them apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Address already in use"
&lt;/h3&gt;

&lt;p&gt;Something on your machine already listens on the local port, often a previous tunnel you forgot about. With an explicit bind address, ssh printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bind [127.0.0.1]:9100: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 9100
Could not request local forwarding.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the trap: without &lt;code&gt;ExitOnForwardFailure&lt;/code&gt;, ssh prints that and &lt;strong&gt;keeps running&lt;/strong&gt;. You have a connected session and no tunnel. With &lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt;, the same command exited with status 255, which is what you want in scripts. Find the old listener with &lt;code&gt;ss -ltnp | grep 9100&lt;/code&gt; (Linux) or &lt;code&gt;lsof -iTCP:9100 -sTCP:LISTEN&lt;/code&gt; (macOS) and stop it, or pick another port.&lt;/p&gt;

&lt;p&gt;One subtlety we hit while testing: with no bind address (&lt;code&gt;-L 9100:...&lt;/code&gt;), ssh tries both &lt;code&gt;127.0.0.1&lt;/code&gt; and &lt;code&gt;::1&lt;/code&gt;. When only the IPv4 side was taken, the IPv6 listener still came up, so ssh didn't count it as a failure even with &lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt;. Clients that connect to &lt;code&gt;127.0.0.1&lt;/code&gt; still hit the other program. If you see the bind warning, don't trust the tunnel until you've checked which process owns the port.&lt;/p&gt;

&lt;h3&gt;
  
  
  "administratively prohibited: open failed"
&lt;/h3&gt;

&lt;p&gt;The server refuses forwarding. With &lt;code&gt;AllowTcpForwarding no&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt;, the tunnel connected fine, and the error only appeared when something used the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;channel 2: open failed: administratively prohibited: open failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server log recorded &lt;code&gt;refused local port forward&lt;/code&gt; with the target host and port. A remote forward against the same server failed up front instead: &lt;code&gt;Error: remote port forwarding failed for listen port 9601&lt;/code&gt;. &lt;code&gt;AllowTcpForwarding&lt;/code&gt; can also be &lt;code&gt;local&lt;/code&gt; or &lt;code&gt;remote&lt;/code&gt; to allow only one direction, and &lt;code&gt;PermitOpen&lt;/code&gt; / &lt;code&gt;PermitListen&lt;/code&gt; restrict which destinations and listen ports are allowed, so a forward to one port can work while another is refused. If it's not your server, ask the admin; don't route around the policy.&lt;/p&gt;

&lt;p&gt;For admins, the &lt;code&gt;sshd_config&lt;/code&gt; man page adds an honest caveat: "disabling TCP forwarding does not improve security unless users are also denied shell access, as they can always install their own forwarders." Turning it off is a policy signal and a speed bump, not a wall. Agent forwarding is a separate setting with its own risks; see &lt;a href="https://termique.app/blog/ssh-agent-forwarding-security-risks-explained/" rel="noopener noreferrer"&gt;SSH agent forwarding security risks&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tunnel is up but the destination refuses
&lt;/h3&gt;

&lt;p&gt;If your client's connection closes immediately and the ssh window prints this, ssh did its part and the server couldn't reach the destination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;channel 2: open failed: connect failed: Connection refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the service listens where you pointed it (&lt;code&gt;ss -ltn&lt;/code&gt; on the server), and remember the destination is resolved from the server: &lt;code&gt;localhost&lt;/code&gt; there is the server itself, and a service bound to a Docker bridge or a private IP won't answer on &lt;code&gt;localhost&lt;/code&gt;. &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; doesn't cover this case; the man page notes it "does not apply to connections made over port forwardings."&lt;/p&gt;

&lt;h3&gt;
  
  
  The tunnel dies when idle
&lt;/h3&gt;

&lt;p&gt;A tunnel that carries no traffic looks idle to NAT gateways and firewalls, which drop idle connections. Set &lt;code&gt;ServerAliveInterval&lt;/code&gt; on the client so ssh sends keepalives, or use &lt;code&gt;autossh&lt;/code&gt; if the tunnel must come back on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is SSH port forwarding secure?
&lt;/h2&gt;

&lt;p&gt;The traffic between your machine and the SSH server is encrypted and authenticated like any SSH session, so a local forward to a database is a reasonable replacement for exposing the database port. The risks come from what you open on either end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A forward bound to &lt;code&gt;0.0.0.0&lt;/code&gt; (via &lt;code&gt;-g&lt;/code&gt;, &lt;code&gt;GatewayPorts&lt;/code&gt;, or an explicit bind address) is reachable by anyone who can reach that interface. Keep forwards on loopback unless you mean otherwise.&lt;/li&gt;
&lt;li&gt;A remote forward with &lt;code&gt;GatewayPorts yes&lt;/code&gt; exposes a service running on your laptop to the server's network.&lt;/li&gt;
&lt;li&gt;On a multi-user server, any local user can connect to a loopback port you forwarded there.&lt;/li&gt;
&lt;li&gt;Forwarding is allowed by default in OpenSSH. If your hardening checklist doesn't mention it, decide on purpose; our &lt;a href="https://termique.app/blog/complete-guide-ssh-security-2026/" rel="noopener noreferrer"&gt;complete guide to SSH security&lt;/a&gt; covers where it fits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where termique fits
&lt;/h2&gt;

&lt;p&gt;If you jump between a few servers every day, the tunnels pile up in your shell history. termique lists port forwarding as part of its Free plan, alongside SSH sessions, SFTP and snippets (see &lt;a href="https://termique.app/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). Hosts and groups sync across your devices, and credentials and private keys sync end-to-end encrypted, so the keys behind your tunnels aren't stored in plaintext on our servers. The &lt;code&gt;ssh&lt;/code&gt; commands in this guide stay useful either way: they're what you reach for on a box where nothing is installed.&lt;/p&gt;

&lt;p&gt;termique is a free, native SSH manager we build, with end-to-end encrypted credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between -L and -R in SSH?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;-L&lt;/code&gt; opens the listening port on your machine and connects onward from the server. &lt;code&gt;-R&lt;/code&gt; opens the listening port on the server and connects onward from your machine. Ask "where does the port I connect to live?" and that's your answer: local means &lt;code&gt;-L&lt;/code&gt;, remote means &lt;code&gt;-R&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does -N do in ssh port forwarding?
&lt;/h3&gt;

&lt;p&gt;It tells ssh not to run a remote command or shell, so the connection exists only to carry your forwards. In &lt;code&gt;~/.ssh/config&lt;/code&gt; the same setting is &lt;code&gt;SessionType none&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I set up SSH port forwarding in the ssh config file?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;LocalForward 5433 localhost:5432&lt;/code&gt;, &lt;code&gt;RemoteForward 8080 localhost:3000&lt;/code&gt; or &lt;code&gt;DynamicForward 1080&lt;/code&gt; inside a &lt;code&gt;Host&lt;/code&gt; block. Add &lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt; so ssh exits instead of running without the tunnel, and check the result with &lt;code&gt;ssh -G &amp;lt;host&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I forward multiple ports over one SSH connection?
&lt;/h3&gt;

&lt;p&gt;Yes. Repeat &lt;code&gt;-L&lt;/code&gt; (or &lt;code&gt;LocalForward&lt;/code&gt;) once per port. All forwards share the one authenticated connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my remote forward only listen on 127.0.0.1?
&lt;/h3&gt;

&lt;p&gt;Because the server's &lt;code&gt;GatewayPorts&lt;/code&gt; setting defaults to &lt;code&gt;no&lt;/code&gt;, which binds remote forwards to loopback even if you ask for &lt;code&gt;0.0.0.0&lt;/code&gt;. Set &lt;code&gt;GatewayPorts clientspecified&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt; if other hosts must reach it, and protect what you expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try termique
&lt;/h2&gt;

&lt;p&gt;Tunnels are one part of working across servers. termique keeps the rest in one place: your host list on every device, end-to-end encrypted credentials, SFTP and saved snippets. &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;Download termique&lt;/a&gt; and add your first hosts.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Post-Quantum SSH Warning: What It Means, How to Fix (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Thu, 29 Oct 2026 03:20:46 +0000</pubDate>
      <link>https://dev.to/masitings/post-quantum-ssh-warning-what-it-means-how-to-fix-2026-5g62</link>
      <guid>https://dev.to/masitings/post-quantum-ssh-warning-what-it-means-how-to-fix-2026-5g62</guid>
      <description>&lt;p&gt;Since OpenSSH 10.1, &lt;code&gt;ssh&lt;/code&gt; prints a three-line warning when a connection falls back to a key exchange that a future quantum computer could break. If you just saw it for the first time, nothing is broken and nobody is attacking you. The post quantum ssh warning means one thing: this session's encryption keys were agreed with classical cryptography only, so someone who records the traffic today could, in principle, decrypt it years from now.&lt;/p&gt;

&lt;p&gt;This guide shows what the warning means, how to check which key exchange your sessions actually use, and how to fix it on the server, on the client, or both. Every command below was run against a local OpenSSH 9.6 server and against github.com, gitlab.com and bitbucket.org on October 26, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What "connection is not using a post-quantum key exchange algorithm" means and why OpenSSH added it.&lt;/li&gt;
&lt;li&gt;One command that shows which key exchange a session negotiated, and one that shows what the server offers.&lt;/li&gt;
&lt;li&gt;Why the fix is sometimes on your laptop, not the server (GitLab and Bitbucket are a live example).&lt;/li&gt;
&lt;li&gt;How to fix &lt;code&gt;KexAlgorithms&lt;/code&gt; on a server without locking yourself out.&lt;/li&gt;
&lt;li&gt;How to silence the warning for one host without breaking older ssh clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does "connection is not using a post-quantum key exchange algorithm" mean?
&lt;/h2&gt;

&lt;p&gt;This is the exact warning, as published by the OpenSSH project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means your client and the server could not agree on either of the two post-quantum key exchange algorithms OpenSSH supports, &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; or &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt;, and fell back to a classical one such as &lt;code&gt;curve25519-sha256&lt;/code&gt; or an &lt;code&gt;ecdh-sha2-nistp*&lt;/code&gt; curve.&lt;/p&gt;

&lt;p&gt;The connection still works, and it's still encrypted with strong ciphers. What changes is the threat model. The key exchange decides the session keys, and if an attacker can break the key exchange, they can decrypt the whole recorded session. Classical Diffie-Hellman and elliptic-curve exchanges are the kind of math a large enough quantum computer is expected to solve. Nobody has built one that can do it yet, but traffic captured now can be stored and decrypted later. That's the "store now, decrypt later" attack the warning names.&lt;/p&gt;

&lt;p&gt;The warning exists to push the long tail of servers to upgrade. OpenSSH has offered a post-quantum key exchange by default since 9.0 (April 2022) and switched the default to &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; in 10.0 (April 2025). Most current OpenSSH pairs already negotiate a post-quantum algorithm without anyone touching a config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which OpenSSH versions support post-quantum key exchange?
&lt;/h2&gt;

&lt;p&gt;The timeline, from the OpenSSH release notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSH 9.0 (2022):&lt;/strong&gt; &lt;code&gt;sntrup761x25519-sha512@openssh.com&lt;/code&gt; (Streamlined NTRU Prime + X25519) becomes the default key exchange.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSH 9.9 (2024):&lt;/strong&gt; adds &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; (ML-KEM, standardized by NIST as FIPS 203, + X25519) and the unsuffixed name &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSH 10.0 (April 2025):&lt;/strong&gt; &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; becomes the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSH 10.1 (October 2025):&lt;/strong&gt; the client warns on non-post-quantum key exchange. A new &lt;code&gt;WarnWeakCrypto&lt;/code&gt; option in &lt;code&gt;ssh_config&lt;/code&gt; controls it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSH 10.6 (October 2026):&lt;/strong&gt; &lt;code&gt;sshd_config&lt;/code&gt; gets &lt;code&gt;WarnWeakCrypto&lt;/code&gt; too, on by default, so servers log clients that use a non-post-quantum key exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both algorithms are hybrids. Each combines a post-quantum scheme with X25519, so even if the post-quantum half turned out to be broken, the session is no weaker than plain &lt;code&gt;curve25519-sha256&lt;/code&gt;. That's why there's no real downside to preferring them.&lt;/p&gt;

&lt;p&gt;One detail that matters in practice: a client and a server both need to support the same post-quantum algorithm. An OpenSSH 9.6 client, for example, only knows &lt;code&gt;sntrup761x25519-sha512@openssh.com&lt;/code&gt;. That's the version on Ubuntu 24.04, which is what we tested from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-V&lt;/span&gt;
OpenSSH_9.6p1 Ubuntu-3ubuntu13.20, OpenSSL 3.0.13 30 Jan 2024
&lt;span class="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-Q&lt;/span&gt; kex | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'mlkem|sntrup'&lt;/span&gt;
sntrup761x25519-sha512@openssh.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you check which key exchange an SSH session uses?
&lt;/h2&gt;

&lt;p&gt;Run the connection in verbose mode and grep for the negotiated algorithm. This is the same check GitHub gives in its announcement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-v&lt;/span&gt; git@github.com &lt;span class="nb"&gt;exit &lt;/span&gt;2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'kex: algorithm:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug1: kex: algorithm: sntrup761x25519-sha512@openssh.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; or &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt; (with or without &lt;code&gt;@openssh.com&lt;/code&gt;), the session is post-quantum. If you see &lt;code&gt;curve25519-sha256&lt;/code&gt;, any &lt;code&gt;ecdh-sha2-*&lt;/code&gt; or any &lt;code&gt;diffie-hellman-*&lt;/code&gt;, it isn't.&lt;/p&gt;

&lt;p&gt;To see what the server is willing to offer, which is what you need when deciding who has to change, go one level more verbose and read the server's proposal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-vvv&lt;/span&gt; user@host &lt;span class="nb"&gt;exit &lt;/span&gt;2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A2&lt;/span&gt; &lt;span class="s1"&gt;'peer server KEXINIT'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'KEX algorithms'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more checks that don't need a connection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ssh -Q kex&lt;/code&gt; lists every key exchange your client binary supports.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh -G host | grep -i kexalgorithms&lt;/code&gt; prints the list your client will actually offer to that host after reading &lt;code&gt;~/.ssh/config&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On the server, &lt;code&gt;sudo sshd -T | grep -i kexalgorithms&lt;/code&gt; prints the effective server list after all includes and &lt;code&gt;Match&lt;/code&gt; blocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why do some Git hosts still negotiate classical key exchange?
&lt;/h2&gt;

&lt;p&gt;Here's what we got on October 26, 2026, connecting from the OpenSSH 9.6 client above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;github.com&lt;/strong&gt; offers &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt;, &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt; and the &lt;code&gt;@openssh.com&lt;/code&gt; name. Negotiated: &lt;code&gt;sntrup761x25519-sha512@openssh.com&lt;/code&gt;. Post-quantum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gitlab.com&lt;/strong&gt; offers &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; but no &lt;code&gt;sntrup761&lt;/code&gt;. Negotiated: &lt;code&gt;curve25519-sha256&lt;/code&gt;. Classical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bitbucket.org&lt;/strong&gt; offers &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; but no &lt;code&gt;sntrup761&lt;/code&gt;. Negotiated: &lt;code&gt;curve25519-sha256&lt;/code&gt;. Classical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitLab and Bitbucket aren't the problem here. Both offer ML-KEM. Our 9.6 client doesn't speak ML-KEM, so the only overlap left is classical. A client on OpenSSH 9.9 or newer would get a post-quantum key exchange with all three, and 10.0 or newer prefers &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the part the warning text can mislead you on. It says "the server may need to be upgraded", and it's printed by a client that is new enough to know ML-KEM, so for that client the server usually is the gap. But if you have older clients around (CI runners on an LTS image, a jump host nobody has upgraded, a vendor appliance), they will quietly negotiate classical key exchange with servers that are fully post-quantum ready, and they'll never warn you, because the warning only exists from 10.1 on.&lt;/p&gt;

&lt;p&gt;GitHub's own announcement has one more exception worth knowing: GitHub Enterprise Cloud with data residency in the US region doesn't offer &lt;code&gt;sntrup761&lt;/code&gt;, because only FIPS-approved cryptography may be used there and that algorithm isn't FIPS-approved. If you're in a FIPS environment, expect your options to differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix the warning on a server you control?
&lt;/h2&gt;

&lt;p&gt;There are two causes, and they need different fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The server's OpenSSH is too old.&lt;/strong&gt; Anything before 9.0 has no post-quantum key exchange at all. Check the version from the outside with &lt;code&gt;ssh -v user@host 2&amp;gt;&amp;amp;1 | grep 'remote software version'&lt;/code&gt;, or on the box with &lt;code&gt;sshd -V&lt;/code&gt; (OpenSSH 9.2 and later) or your package manager. Distribution packages often carry an older upstream version number with security fixes backported, so read the number as "which features exist", not "is it vulnerable". If the version is below 9.0, the fix is a distribution upgrade or a newer OpenSSH package, not a config change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The server is new enough, but &lt;code&gt;KexAlgorithms&lt;/code&gt; strips the post-quantum algorithms.&lt;/strong&gt; This is common on servers hardened with an older guide that pinned an explicit list. We reproduced it on OpenSSH 9.6 with one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;KexAlgorithms&lt;/span&gt; curve25519-sha256,curve25519-sha256@libssh.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that line, every client negotiates &lt;code&gt;curve25519-sha256&lt;/code&gt;, even ones that support post-quantum algorithms. Find it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; kexalgorithms
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rni&lt;/span&gt; kexalgorithms /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleanest fix is to delete the pinned line and let the OpenSSH default apply, which already puts the post-quantum algorithms first. If you must keep an explicit list, put a post-quantum algorithm in it. On 9.6 that means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 9.9 or newer, add &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; (and &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt;) as well. Only list algorithms your &lt;code&gt;ssh -Q kex&lt;/code&gt; output on the server shows; an unknown name is a config error.&lt;/p&gt;

&lt;p&gt;Then apply it the safe way. Test the config, reload, and keep your current session open while you check from a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload ssh   &lt;span class="c"&gt;# the unit is "sshd" on RHEL-family systems&lt;/span&gt;
&lt;span class="c"&gt;# from a NEW terminal, keep the old session open:&lt;/span&gt;
ssh &lt;span class="nt"&gt;-v&lt;/span&gt; user@host &lt;span class="nb"&gt;exit &lt;/span&gt;2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'kex: algorithm:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sshd -t&lt;/code&gt; matters more than usual here. If a config line is wrong, a restart can leave sshd not running, and the open session you kept is your way back in.&lt;/p&gt;

&lt;p&gt;If your server sits behind a bastion, remember each hop is its own SSH connection with its own key exchange. With &lt;code&gt;ProxyJump&lt;/code&gt;, check both the jump host and the target; our &lt;a href="https://termique.app/blog/bastion-host-proxyjump-setup/" rel="noopener noreferrer"&gt;bastion host and ProxyJump guide&lt;/a&gt; covers how the hops connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it from the client side?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upgrade the client.&lt;/strong&gt; If your client is older than 9.9, you can't negotiate ML-KEM, and some servers (like GitLab and Bitbucket in our test) only offer ML-KEM as their post-quantum option. On a laptop that usually means a newer OS release or a newer OpenSSH package. On CI runners, it means a newer base image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your own &lt;code&gt;~/.ssh/config&lt;/code&gt;.&lt;/strong&gt; A pinned &lt;code&gt;KexAlgorithms&lt;/code&gt; line on the client has the same effect as one on the server. &lt;code&gt;ssh -G host | grep -i kexalgorithms&lt;/code&gt; shows what you'll offer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Require post-quantum for hosts that matter.&lt;/strong&gt; You can make the client refuse classical key exchange for a specific host, instead of just warning about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; git.internal.example.com
    &lt;span class="k"&gt;KexAlgorithms&lt;/span&gt; mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use only names your &lt;code&gt;ssh -Q kex&lt;/code&gt; knows (on 9.6, only the &lt;code&gt;@openssh.com&lt;/code&gt; sntrup name). If the server can't match, the connection fails instead of falling back. This is the error we got from a 9.6 client forcing sntrup against a server limited to curve25519:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to negotiate with 127.0.0.1 port 2299: no matching key exchange method found. Their offer: curve25519-sha256,curve25519-sha256@libssh.org,ext-info-s,kex-strict-s-v00@openssh.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a loud failure by design. Use it for hosts where you've confirmed post-quantum support, not as a global default. If you keep a large config, our &lt;a href="https://termique.app/blog/ssh-config-file-organization-tips-devops/" rel="noopener noreferrer"&gt;ssh config organization guide&lt;/a&gt; shows how to group these per-host rules so they don't sprawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you silence the post-quantum warning safely?
&lt;/h2&gt;

&lt;p&gt;Sometimes you can't fix the other end: a network switch, an embedded device, a vendor appliance with its own SSH server. OpenSSH recommends turning the warning off selectively, not globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Match&lt;/span&gt; host unsafe.example.com
    &lt;span class="k"&gt;WarnWeakCrypto&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;-pq-kex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;no-pq-kex&lt;/code&gt; disables only the post-quantum warning. &lt;code&gt;WarnWeakCrypto no&lt;/code&gt; disables all of these warnings, including any OpenSSH adds later, so avoid it.&lt;/p&gt;

&lt;p&gt;There's a trap here if you share one &lt;code&gt;~/.ssh/config&lt;/code&gt; across machines, or check it into a dotfiles repo. Clients older than 10.1 don't know &lt;code&gt;WarnWeakCrypto&lt;/code&gt; and refuse to run. On OpenSSH 9.6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config: line 2: Bad configuration option: warnweakcrypto
config: terminating, 1 bad configuration options
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That breaks every ssh command on that machine, not just the one host. Put this at the very top of the file, before any &lt;code&gt;Host&lt;/code&gt; or &lt;code&gt;Match&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IgnoreUnknown WarnWeakCrypto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IgnoreUnknown&lt;/code&gt; only applies to options that appear after it, which is why it has to come first.&lt;/p&gt;

&lt;p&gt;The same trap exists on servers, with no &lt;code&gt;IgnoreUnknown&lt;/code&gt; escape hatch. &lt;code&gt;WarnWeakCrypto&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt; is new in 10.6. On our 9.6 server, &lt;code&gt;sshd -t&lt;/code&gt; rejected it with &lt;code&gt;Bad configuration option: WarnWeakCrypto&lt;/code&gt;. Don't push it to a fleet of mixed versions through one shared config template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you also need post-quantum SSH keys?
&lt;/h2&gt;

&lt;p&gt;Not urgently. The key exchange protects the secrecy of the traffic, and that's what "store now, decrypt later" targets. Your SSH keys (user keys and host keys) are used for signatures, which prove who you are at connection time. A future quantum computer could forge signatures for keys that are still trusted then, but it can't use that to decrypt sessions you recorded in the past.&lt;/p&gt;

&lt;p&gt;OpenSSH has started on post-quantum signatures anyway. OpenSSH 10.6 enabled the hybrid &lt;code&gt;ssh-mldsa44-ed25519&lt;/code&gt; algorithm, and keys generated with the earlier experimental version have to be regenerated. Server and client support is still thin, so for most people the right move today is to keep using Ed25519 and plan to rotate when your servers and Git hosts accept the new type. Our &lt;a href="https://termique.app/blog/ssh-keygen-best-practices-2026/" rel="noopener noreferrer"&gt;ssh-keygen best practices guide&lt;/a&gt; covers where &lt;code&gt;mldsa44-ed25519&lt;/code&gt; stands and what to generate now.&lt;/p&gt;

&lt;p&gt;Order of work, if you're auditing a fleet: key exchange first (it protects traffic recorded today), signatures later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does termique fit?
&lt;/h2&gt;

&lt;p&gt;A GUI SSH client uses its own SSH library, so your &lt;code&gt;~/.ssh/config&lt;/code&gt; &lt;code&gt;KexAlgorithms&lt;/code&gt; and &lt;code&gt;WarnWeakCrypto&lt;/code&gt; lines don't apply to it. That's true for termique too. termique's docs don't currently list which key exchange algorithms its connections negotiate, so for a connection where post-quantum key exchange is a requirement, verify it with &lt;code&gt;ssh -v&lt;/code&gt; from a terminal against the same host.&lt;/p&gt;

&lt;p&gt;What termique does handle is the part around the session. It saves each host's key on first connect and blocks the connection, before sending any credential, if a host later presents a different key, showing the new key's &lt;code&gt;SHA256:&lt;/code&gt; fingerprint so you can compare it with the server. Our &lt;a href="https://termique.app/blog/ssh-host-key-verification-explained/" rel="noopener noreferrer"&gt;host key verification guide&lt;/a&gt; explains why that check matters. Your SSH passwords and private keys are end-to-end encrypted before they sync between devices, so the sync server only stores ciphertext; host names, groups and snippets sync as plain metadata. The details are on our &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the wider checklist (key types, password auth, hardening, auditing), see the &lt;a href="https://termique.app/blog/complete-guide-ssh-security-2026/" rel="noopener noreferrer"&gt;SSH security best practices guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the post-quantum SSH warning an attack?
&lt;/h3&gt;

&lt;p&gt;No. It means your session negotiated a classical key exchange. The connection is still encrypted and authenticated. The risk is long term: recorded traffic could be decrypted once a large enough quantum computer exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check if my SSH connection is post-quantum?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;ssh -v user@host exit 2&amp;gt;&amp;amp;1 | grep 'kex: algorithm:'&lt;/code&gt;. If it shows &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; or &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt;, it's post-quantum. Anything else is classical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does GitHub support post-quantum SSH?
&lt;/h3&gt;

&lt;p&gt;Yes. GitHub added &lt;code&gt;sntrup761x25519-sha512&lt;/code&gt; on September 17, 2025, and in our October 2026 test github.com also offered &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt;. GitHub Enterprise Cloud with US data residency is the exception, because only FIPS-approved algorithms are used there.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I turn off the post-quantum warning for one server?
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;Match host that.server.example&lt;/code&gt; with &lt;code&gt;WarnWeakCrypto no-pq-kex&lt;/code&gt; under it, and put &lt;code&gt;IgnoreUnknown WarnWeakCrypto&lt;/code&gt; at the top of your config so clients older than 10.1 don't fail on the unknown option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which is better, mlkem768x25519-sha256 or sntrup761x25519-sha512?
&lt;/h3&gt;

&lt;p&gt;Both are hybrids with X25519 and both protect against "store now, decrypt later". OpenSSH made &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; the default in 10.0; it's NIST-standardized and faster. Use whichever both ends support; with a recent OpenSSH you don't have to choose.&lt;/p&gt;

&lt;p&gt;If you juggle more servers than you can track by hand, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;try termique free&lt;/a&gt;: hosts in one place, host keys checked before credentials are sent, and credentials encrypted before they sync.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Use ssh-agent on Linux, macOS and Windows (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Mon, 26 Oct 2026 03:20:48 +0000</pubDate>
      <link>https://dev.to/masitings/how-to-use-ssh-agent-on-linux-macos-and-windows-2026-4pfk</link>
      <guid>https://dev.to/masitings/how-to-use-ssh-agent-on-linux-macos-and-windows-2026-4pfk</guid>
      <description>&lt;p&gt;You gave your SSH key a passphrase, which is the right call. Now you type it forty times a day, or you quietly removed it last week because it got in the way. ssh-agent exists so you don't have to pick between those two. It holds your decrypted key in memory, signs authentication requests for &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;scp&lt;/code&gt;, and never hands the key itself to anyone.&lt;/p&gt;

&lt;p&gt;This guide covers how to use ssh-agent on each platform, the handful of &lt;code&gt;ssh-add&lt;/code&gt; flags worth knowing, the two errors people actually hit, and the settings that keep a running agent from becoming a liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How to start ssh-agent and add a key on Linux, macOS and Windows 11.&lt;/li&gt;
&lt;li&gt;How to list, test and remove keys with &lt;code&gt;ssh-add&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;How to fix "unable to start ssh-agent service, error :1058" on Windows.&lt;/li&gt;
&lt;li&gt;Why an agent full of keys causes "Too many authentication failures", and the two-line fix.&lt;/li&gt;
&lt;li&gt;ssh-agent best practices: key lifetimes, confirmation, destination constraints and locking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you use ssh-agent?
&lt;/h2&gt;

&lt;p&gt;Three steps, the same on every platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make sure an agent is running&lt;/strong&gt; and that your shell knows where its socket is (the &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; environment variable on Linux and macOS, a Windows service on Windows).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add your key&lt;/strong&gt; with &lt;code&gt;ssh-add&lt;/code&gt;. You type the passphrase once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect as usual.&lt;/strong&gt; &lt;code&gt;ssh&lt;/code&gt; finds the agent through &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; and asks it to sign the login challenge. The private key never leaves the agent process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Linux, in a shell where no agent is running yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ssh-add ~/.ssh/id_ed25519
ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh-agent -s&lt;/code&gt; prints Bourne-shell commands that set &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; and &lt;code&gt;SSH_AGENT_PID&lt;/code&gt;; &lt;code&gt;eval&lt;/code&gt; runs them in your current shell. Use &lt;code&gt;-c&lt;/code&gt; instead of &lt;code&gt;-s&lt;/code&gt; if you live in csh or tcsh. &lt;code&gt;ssh-add -l&lt;/code&gt; should print one line with the key's SHA256 fingerprint.&lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;ssh-add&lt;/code&gt; without a file name, it tries the default identities: &lt;code&gt;~/.ssh/id_rsa&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ecdsa&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ecdsa_sk&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt;, &lt;code&gt;~/.ssh/id_ed25519_sk&lt;/code&gt;, and on current OpenSSH also &lt;code&gt;~/.ssh/id_mldsa44_ed25519&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does ssh-agent actually protect?
&lt;/h3&gt;

&lt;p&gt;It keeps the decrypted key out of files and out of the processes that use it. Your passphrase-protected key stays encrypted on disk; only the agent holds the plaintext version, in memory. Clients talk to it over a Unix socket that only your user can open.&lt;/p&gt;

&lt;p&gt;What it does not protect against is someone who can open that socket. The ssh-agent man page is blunt about it: the socket "is accessible only to the current user, but is easily abused by root or another instance of the same user." Anyone who can reach the socket can't copy your key, but they can use it to log in wherever the key works. Keep that in mind for the best-practice section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you start ssh-agent automatically on Linux?
&lt;/h2&gt;

&lt;p&gt;Most desktop sessions (GNOME, KDE and others) already start an agent or an agent-compatible keyring at login. Check before adding another one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh-add&lt;/code&gt; exits with status 2 when it can't reach an agent at all, and 1 when the agent is running but holds no keys (it prints "The agent has no identities."). That difference is useful in scripts.&lt;/p&gt;

&lt;p&gt;On a server or a minimal desktop with no agent, running &lt;code&gt;eval "$(ssh-agent -s)"&lt;/code&gt; in every new terminal gives you one agent per shell, each with its own keys. A cleaner pattern is one agent per login on a fixed socket path, reused by every shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in ~/.bashrc or ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.ssh/agent.sock"&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 8h&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-a&lt;/code&gt; binds the agent to a socket path you choose, so every shell finds the same agent. The &lt;code&gt;rm -f&lt;/code&gt; clears a stale socket left over from a crash, because &lt;code&gt;ssh-agent&lt;/code&gt; won't bind to a path that already exists. &lt;code&gt;-t 8h&lt;/code&gt; sets a default lifetime for every key added to this agent; more on that below.&lt;/p&gt;

&lt;p&gt;Where does the socket live if you don't pick one? Recent OpenSSH releases create it under &lt;code&gt;~/.ssh/agent/&lt;/code&gt; with a random &lt;code&gt;s.*&lt;/code&gt; name. Older releases, which many distributions still ship, use a random directory under &lt;code&gt;/tmp&lt;/code&gt; instead. Either way, &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; tells you the real path.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use ssh-agent on macOS?
&lt;/h2&gt;

&lt;p&gt;macOS starts an agent for your login session through launchd, so &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; is usually set already and you skip the &lt;code&gt;eval&lt;/code&gt; step. Apple's build of &lt;code&gt;ssh-add&lt;/code&gt; adds one option the upstream tool doesn't have: it can store your key's passphrase in the macOS Keychain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;--apple-use-keychain&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To have keys loaded on first use instead of by hand, add this to &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; *
  &lt;span class="k"&gt;AddKeysToAgent&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
  &lt;span class="k"&gt;UseKeychain&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UseKeychain&lt;/code&gt; only exists in Apple's OpenSSH. If you also use Homebrew's &lt;code&gt;ssh&lt;/code&gt;, or share this config file with Linux machines, add &lt;code&gt;IgnoreUnknown UseKeychain&lt;/code&gt; above it so other builds don't fail with "Bad configuration option: usekeychain".&lt;/li&gt;
&lt;li&gt;Before macOS Monterey (12.0), the flags were &lt;code&gt;-K&lt;/code&gt; and &lt;code&gt;-A&lt;/code&gt; instead of &lt;code&gt;--apple-use-keychain&lt;/code&gt; and &lt;code&gt;--apple-load-keychain&lt;/code&gt;. Old blog posts that tell you to run &lt;code&gt;ssh-add -K&lt;/code&gt; are describing that older syntax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you use ssh-agent on Windows 11?
&lt;/h2&gt;

&lt;p&gt;Windows ships OpenSSH as a system component, and its agent runs as a Windows service called &lt;code&gt;ssh-agent&lt;/code&gt;. Microsoft's docs note that the service is disabled by default. Enable and start it from an elevated PowerShell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Set-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StartupType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Automatic&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Start-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line should show &lt;code&gt;Running&lt;/code&gt;. Then, from a normal (non-admin) PowerShell, add your key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;ssh-add&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="nx"&gt;\.ssh\id_ed25519&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;ssh-add&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike the Unix agent, the Windows service stores added keys under your Windows account, so they are still there after a reboot. Microsoft's guide goes as far as suggesting you back up the key file and delete it locally once it's in the agent. If you do that, make sure the backup really exists first: the agent will not give the private key back to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you fix "unable to start ssh-agent service, error :1058"?
&lt;/h3&gt;

&lt;p&gt;Error 1058 is Windows saying the service can't be started because it is disabled. That's the default state of &lt;code&gt;ssh-agent&lt;/code&gt;, so this error almost always means nobody ran the &lt;code&gt;Set-Service&lt;/code&gt; line above. Open PowerShell as administrator, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Set-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StartupType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Automatic&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Start-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and try &lt;code&gt;ssh-add&lt;/code&gt; again. Use &lt;code&gt;-StartupType Manual&lt;/code&gt; instead if you'd rather start the service yourself each time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Git on Windows keep asking for my passphrase?
&lt;/h3&gt;

&lt;p&gt;Git for Windows can bring its own &lt;code&gt;ssh.exe&lt;/code&gt; (from its MSYS2 environment). That binary doesn't talk to the Windows &lt;code&gt;ssh-agent&lt;/code&gt; service, so the key you added in PowerShell is invisible to &lt;code&gt;git push&lt;/code&gt;. Point Git at the system OpenSSH client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; core.sshCommand &lt;span class="s2"&gt;"C:/Windows/System32/OpenSSH/ssh.exe"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or reinstall Git for Windows and pick the "Use external OpenSSH" option.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you list, test and remove keys with ssh-add?
&lt;/h2&gt;

&lt;p&gt;The day-to-day flags, all from the &lt;code&gt;ssh-add&lt;/code&gt; man page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -l&lt;/code&gt; lists fingerprints of every key the agent holds. &lt;code&gt;ssh-add -L&lt;/code&gt; prints the full public keys instead, handy for pasting into &lt;code&gt;authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -T ~/.ssh/id_ed25519.pub&lt;/code&gt; tests whether the agent can actually sign with the private key behind that public key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -d ~/.ssh/id_ed25519&lt;/code&gt; removes one key. It wants the public key file; if you pass the private key path, it appends &lt;code&gt;.pub&lt;/code&gt; and tries again.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -D&lt;/code&gt; removes every key. Good habit at the end of the day or before you hand your laptop to someone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -x&lt;/code&gt; locks the agent with a password, &lt;code&gt;ssh-add -X&lt;/code&gt; unlocks it. While locked, the keys stay loaded but can't be used.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh-add -v&lt;/code&gt; (up to &lt;code&gt;-vvv&lt;/code&gt;) prints debug output when something doesn't load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ssh-add&lt;/code&gt; also refuses to load a key file that other users can read. If it complains about permissions, &lt;code&gt;chmod 600 ~/.ssh/id_ed25519&lt;/code&gt; fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does ssh-agent cause "Too many authentication failures"?
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;ssh&lt;/code&gt; connects, it offers the server every key the agent holds, one after another, before it gets to the one you meant. Each rejected key counts as a failed attempt. The server's &lt;code&gt;MaxAuthTries&lt;/code&gt; defaults to 6, so once six keys that are wrong for this server have been offered, you get disconnected with "Too many authentication failures" before the right key is ever tried. With a handful of project keys loaded, that happens sooner than you'd think.&lt;/p&gt;

&lt;p&gt;The fix is to tell &lt;code&gt;ssh&lt;/code&gt; which key belongs to which host, and to stop it from offering the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; prod-db
  &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.0.4.12
  &lt;span class="k"&gt;User&lt;/span&gt; deploy
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519_prod
  &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt; only uses the identity files named in the config or on the command line, even if the agent offers more. The agent still does the signing, so you keep the no-retyping benefit. Our post on &lt;a href="https://termique.app/blog/managing-multiple-ssh-keys-across-devices/" rel="noopener noreferrer"&gt;managing multiple SSH keys across devices&lt;/a&gt; has a naming scheme that makes these blocks easier to maintain, and &lt;a href="https://termique.app/blog/ssh-config-file-organization-tips-devops/" rel="noopener noreferrer"&gt;ssh config file organization&lt;/a&gt; covers how to keep the file readable once it grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are ssh-agent best practices in 2026?
&lt;/h2&gt;

&lt;p&gt;An agent that holds every key forever, for every host, is convenient and a decent target. These settings cut the risk without bringing back the passphrase prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give keys a lifetime
&lt;/h3&gt;

&lt;p&gt;By default a key stays in the agent until the agent exits. Set a limit, either for the whole agent or per key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 8h        &lt;span class="c"&gt;# default lifetime for every key added&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-t&lt;/span&gt; 1h ~/.ssh/id_ed25519_prod   &lt;span class="c"&gt;# this key only, overrides the default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Times use the sshd_config format: plain seconds, or units like &lt;code&gt;30m&lt;/code&gt;, &lt;code&gt;8h&lt;/code&gt;, &lt;code&gt;1h30m&lt;/code&gt;. The same works from &lt;code&gt;~/.ssh/config&lt;/code&gt;, which is the least effort option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; *
  &lt;span class="k"&gt;AddKeysToAgent&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddKeysToAgent&lt;/code&gt; accepts &lt;code&gt;no&lt;/code&gt; (the default), &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;ask&lt;/code&gt;, &lt;code&gt;confirm&lt;/code&gt; (optionally followed by a time), or a time interval on its own. With a time interval, a key is added the first time you use it and removed after that interval.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confirm each use for your most sensitive key
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-c&lt;/span&gt; ~/.ssh/id_ed25519_prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;-c&lt;/code&gt;, the agent asks for confirmation through an &lt;code&gt;ssh-askpass&lt;/code&gt; program every time the key is used. &lt;code&gt;AddKeysToAgent confirm&lt;/code&gt; does the same from the config file. This needs a working askpass program on the machine running the agent, which most Linux desktops have and minimal servers usually don't. It's the strongest guard if you ever forward the agent, because a hijacked socket can't use the key without you clicking yes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restrict where a key can be used
&lt;/h3&gt;

&lt;p&gt;Since OpenSSH 8.9, &lt;code&gt;ssh-add -h&lt;/code&gt; attaches destination constraints to a key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-h&lt;/span&gt; git@github.com ~/.ssh/id_ed25519_github
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key can now only be used from this machine to &lt;code&gt;github.com&lt;/code&gt; as user &lt;code&gt;git&lt;/code&gt;. Hosts are identified by their host keys from your known_hosts files, so a typo-squatted or spoofed host doesn't match. Constraints also apply across forwarded agents, as long as each hop's client and server support them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't forward the agent by default
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ForwardAgent&lt;/code&gt; is &lt;code&gt;no&lt;/code&gt; by default for good reason: anyone with root on the remote host can use your forwarded agent to authenticate as you while you're connected. Use &lt;code&gt;ProxyJump&lt;/code&gt; to reach hosts behind a bastion instead. Our post on &lt;a href="https://termique.app/blog/ssh-agent-forwarding-security-risks-explained/" rel="noopener noreferrer"&gt;ssh agent forwarding security risks&lt;/a&gt; walks through the attack and the alternatives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep a passphrase on the key file anyway
&lt;/h3&gt;

&lt;p&gt;The agent is the reason a passphrase stops being annoying, not a reason to drop it. If the laptop is stolen while powered off, the agent is empty and the key on disk is still encrypted. &lt;a href="https://termique.app/blog/ssh-keygen-best-practices-2026/" rel="noopener noreferrer"&gt;ssh-keygen best practices for 2026&lt;/a&gt; covers the key type and &lt;code&gt;-a&lt;/code&gt; rounds to pick when you generate it. If an existing key keeps rejecting its passphrase, see &lt;a href="https://termique.app/blog/ssh-key-passphrase-not-working/" rel="noopener noreferrer"&gt;ssh key passphrase not working&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clear it when you step away
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ssh-add -D&lt;/code&gt; before you leave, or &lt;code&gt;ssh-add -x&lt;/code&gt; to lock without unloading. On Linux you can also send the agent &lt;code&gt;SIGUSR1&lt;/code&gt;, which deletes all loaded keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does termique fit?
&lt;/h2&gt;

&lt;p&gt;ssh-agent solves the "type the passphrase once" problem for command-line tools. termique, the SSH client we build, takes a different route for the keys you store in it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keys live in an encrypted vault.&lt;/strong&gt; Private keys you generate or import in termique are encrypted with a data key that is unlocked by your master password, and only ciphertext is stored or synced. Details are on the &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One key per host, by design.&lt;/strong&gt; When you add a host, you set the auth type to key and pick which key it uses. That's the same idea as &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;: no client offering every key to every server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locking clears the data key from memory.&lt;/strong&gt; Locking the app drops it from memory until you unlock again, which is the same habit as &lt;code&gt;ssh-add -D&lt;/code&gt; at the end of the day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passphrase-protected keys can be imported&lt;/strong&gt;, so you don't have to strip the passphrase to move a key in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;termique doesn't replace ssh-agent for your terminal, &lt;code&gt;git&lt;/code&gt; or CI. Keep the agent setup above for those, and use termique for interactive sessions across your machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I check if ssh-agent is running?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;ssh-add -l&lt;/code&gt;. Exit status 2 (with a "Could not open a connection to your authentication agent" message) means no agent is reachable from this shell. Exit status 1 with "The agent has no identities." means it's running but empty. On Windows, &lt;code&gt;Get-Service ssh-agent&lt;/code&gt; shows whether the service is running.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I list the keys in ssh-agent?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ssh-add -l&lt;/code&gt; prints fingerprints; &lt;code&gt;ssh-add -L&lt;/code&gt; prints the full public keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I remove a key from ssh-agent?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ssh-add -d path/to/key.pub&lt;/code&gt; removes one key, &lt;code&gt;ssh-add -D&lt;/code&gt; removes all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to leave keys in ssh-agent?
&lt;/h3&gt;

&lt;p&gt;It's reasonably safe on a single-user machine you control, and much safer with a lifetime (&lt;code&gt;-t&lt;/code&gt; or &lt;code&gt;AddKeysToAgent 4h&lt;/code&gt;). The risk is anyone who can reach the agent socket, which includes root on that machine and root on any host you forward the agent to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does ssh-agent forget my keys after a reboot on Linux but not on Windows?
&lt;/h3&gt;

&lt;p&gt;The OpenSSH agent on Linux and macOS keeps keys only in memory, so they're gone when it exits. The Windows &lt;code&gt;ssh-agent&lt;/code&gt; service stores added keys under your Windows account, so they persist until you remove them with &lt;code&gt;ssh-add -d&lt;/code&gt; or &lt;code&gt;ssh-add -D&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try termique free
&lt;/h2&gt;

&lt;p&gt;If you juggle keys across several machines and want one key per host without maintaining the config by hand, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;download termique&lt;/a&gt; and import the keys you already use. Keep ssh-agent running for your terminal and Git; the two work side by side.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>PHP2Shell: Find and Patch Every PHP Server You Run (2026)</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Thu, 22 Oct 2026 19:13:23 +0000</pubDate>
      <link>https://dev.to/masitings/php2shell-find-and-patch-every-php-server-you-run-2026-5hfp</link>
      <guid>https://dev.to/masitings/php2shell-find-and-patch-every-php-server-you-run-2026-5hfp</guid>
      <description>&lt;p&gt;On October 21, 2026, the PHP project shipped emergency releases for PHP2Shell, two critical bugs in the PHP engine itself, tracked as CVE-2026-109618 and CVE-2026-109619. They allow unauthenticated remote code execution and remote reads of worker memory, and they don't depend on your application code. A "Hello World" script on an affected version is enough. This guide is the practical part: how to find every PHP runtime you're actually serving traffic with, patch it, confirm the old workers are gone, and look for signs someone got there first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary: What You'll Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What PHP2Shell is and which setups are at risk&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;php -v&lt;/code&gt; can lie to you about what's serving requests&lt;/li&gt;
&lt;li&gt;How to patch on Debian/Ubuntu, RHEL-family, Docker and FrankenPHP&lt;/li&gt;
&lt;li&gt;Temporary mitigations if you can't patch today, and their limits&lt;/li&gt;
&lt;li&gt;Log signals worth checking for exploitation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is PHP2Shell, and is my server affected?
&lt;/h2&gt;

&lt;p&gt;PHP2Shell is the name Wiz Research gave to a set of memory-safety bugs in how PHP handles fatal errors, mostly out-of-memory errors, while it processes a request. An interrupted request can leave corrupted internal state behind, and a later request handled by the same worker reuses it. Wiz chained these into pre-authentication RCE against the latest WordPress, Drupal and Joomla container images, and separately showed that an attacker can read chunks of worker memory, which can include passwords, tokens and API keys from other requests.&lt;/p&gt;

&lt;p&gt;Per the PHP security advisory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed versions:&lt;/strong&gt; PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At risk:&lt;/strong&gt; every PHP installation serving web requests, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The PHP team rates mod_php as highest risk, because the demonstrated exploit fires before any application code runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP 8.1 and older&lt;/strong&gt; get no upstream release. Your distribution or LTS vendor may ship a backport; the PHP team also published minimal source backports for 7.4, 8.0 and 8.1 for people building from source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Affected range:&lt;/strong&gt; Wiz reports every release back to PHP 5.4 is hit by at least one of the bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run anything on PHP, assume you're affected until you've checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find which PHP versions are actually serving traffic?
&lt;/h2&gt;

&lt;p&gt;This is where most patch rounds go wrong. The CLI binary, the FPM binary and the Apache module are separate packages with separate config, and they can be on different versions. Checking &lt;code&gt;php -v&lt;/code&gt; over SSH tells you about the CLI only.&lt;/p&gt;

&lt;p&gt;Start by finding what's running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,user,args | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'[p]hp-fpm|[p]hp-cgi|[f]rankenphp|[l]sphp'&lt;/span&gt;
apachectl &lt;span class="nt"&gt;-M&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line shows FPM masters and pools, CGI, FrankenPHP and LiteSpeed processes. The second shows whether Apache has mod_php loaded.&lt;/p&gt;

&lt;p&gt;For PHP-FPM, ask the running binary directly rather than whatever &lt;code&gt;php&lt;/code&gt; is on your PATH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FPM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; /proc/&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-o&lt;/span&gt; php-fpm&lt;span class="si"&gt;)&lt;/span&gt;/exe&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FPM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FPM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pgrep -o&lt;/code&gt; picks the oldest matching process, which is the FPM master. If &lt;code&gt;readlink&lt;/code&gt; prints a path ending in &lt;code&gt;(deleted)&lt;/code&gt;, the binary on disk was replaced by an update but the old master is still running the previous code. That server is not patched yet, whatever the package manager says.&lt;/p&gt;

&lt;p&gt;For containers, check inside each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}\t{{.Image}}'&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For FrankenPHP, the PHP version is embedded in the binary. A small script run through its CLI mode tells you which one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;?php echo PHP_VERSION, PHP_EOL;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/phpver.php
frankenphp php-cli /tmp/phpver.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Distro packages: why the version number isn't the whole answer
&lt;/h2&gt;

&lt;p&gt;Debian, Ubuntu and RHEL often backport security fixes into the version they already ship instead of moving to upstream 8.3.36 or 8.2.35. A package can be fixed while &lt;code&gt;php -v&lt;/code&gt; still prints an older-looking number. Check the package changelog for the CVE instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
apt-get changelog php8.3-fpm | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CVE-2026-10961[89]'&lt;/span&gt;

&lt;span class="c"&gt;# RHEL / Alma / Rocky&lt;/span&gt;
rpm &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--changelog&lt;/span&gt; php-fpm | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CVE-2026-10961[89]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the package name for whatever you run (&lt;code&gt;libapache2-mod-php8.3&lt;/code&gt;, &lt;code&gt;php8.2-cgi&lt;/code&gt;, and so on). No match means your vendor hasn't shipped the fix for that package yet, or you're on a repo that doesn't track it.&lt;/p&gt;

&lt;p&gt;One specific warning from the PHP advisory: if you rely on the volunteer-run &lt;code&gt;rpms.remirepo.net&lt;/code&gt;, it did not have updates on disclosure day. The advisory recommends switching to distribution packages or building from source so you can patch immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I patch PHP for PHP2Shell?
&lt;/h2&gt;

&lt;p&gt;The patch itself is ordinary. The part people skip is restarting every worker.&lt;/p&gt;

&lt;p&gt;On Debian or Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--only-upgrade&lt;/span&gt; &lt;span class="s1"&gt;'php8.3*'&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php8.3-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On RHEL-family systems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf upgrade &lt;span class="s1"&gt;'php*'&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use mod_php, restart Apache (&lt;code&gt;systemctl restart apache2&lt;/code&gt; or &lt;code&gt;httpd&lt;/code&gt;), since the module lives inside the Apache workers. For Docker, pull or rebuild the image on a fixed tag and redeploy. If you pin images by digest, the old digest stays vulnerable until you update it. For FrankenPHP, upgrade the binary or image to a release built on a fixed PHP version and restart the service. If you followed our &lt;a href="https://termique.app/blog/deploy-laravel-frankenphp-vps/" rel="noopener noreferrer"&gt;Laravel with FrankenPHP guide&lt;/a&gt;, that means replacing the binary and restarting the systemd unit.&lt;/p&gt;

&lt;p&gt;Then re-run the checks from the previous section. You're done with a host when the running binary reports a fixed version (or the changelog shows the CVE), &lt;code&gt;readlink&lt;/code&gt; no longer shows &lt;code&gt;(deleted)&lt;/code&gt;, and the master process started after the update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;lstart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-o&lt;/span&gt; php-fpm&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What can I do if I can't patch today?
&lt;/h2&gt;

&lt;p&gt;The PHP team is explicit that these measures reduce exposure to specific attack paths and do not make an installation safe. Use them to buy hours, not weeks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disable session upload progress.&lt;/strong&gt; Set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; in &lt;code&gt;php.ini&lt;/code&gt;, the FPM pool or the server config, then restart every worker. Setting it with &lt;code&gt;ini_set()&lt;/code&gt; or a &lt;code&gt;.user.ini&lt;/code&gt; file does not help, because the bug fires during request parsing, before those apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower request size limits.&lt;/strong&gt; &lt;code&gt;post_max_size = 8M&lt;/code&gt; if the app takes no uploads, &lt;code&gt;32M&lt;/code&gt; if it does, with &lt;code&gt;upload_max_filesize&lt;/code&gt; below it. Enforce limits on request body size, multipart part count and header count at the proxy too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the controllable memory-limit failure.&lt;/strong&gt; The advisory suggests &lt;code&gt;memory_limit=-1&lt;/code&gt;, set where applications can't override it, plus a container or cgroup memory limit as the availability boundary. Don't use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt; for this; those can trigger the vulnerable allocation failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On PHP 8.5,&lt;/strong&gt; set &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt; and restart workers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an FPM pool, &lt;code&gt;php_admin_*&lt;/code&gt; values can't be overridden by the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;; /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;php&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt;.&lt;span class="m"&gt;3&lt;/span&gt;/&lt;span class="n"&gt;fpm&lt;/span&gt;/&lt;span class="n"&gt;pool&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;.&lt;span class="n"&gt;conf&lt;/span&gt;
&lt;span class="n"&gt;php_admin_flag&lt;/span&gt;[&lt;span class="n"&gt;session&lt;/span&gt;.&lt;span class="n"&gt;upload_progress&lt;/span&gt;.&lt;span class="n"&gt;enabled&lt;/span&gt;] = &lt;span class="n"&gt;off&lt;/span&gt;
&lt;span class="n"&gt;php_admin_value&lt;/span&gt;[&lt;span class="n"&gt;post_max_size&lt;/span&gt;] = &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;
&lt;span class="n"&gt;php_admin_value&lt;/span&gt;[&lt;span class="n"&gt;upload_max_filesize&lt;/span&gt;] = &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember the CLI has its own &lt;code&gt;php.ini&lt;/code&gt; on most distros (&lt;code&gt;/etc/php/8.3/cli/&lt;/code&gt; vs &lt;code&gt;/etc/php/8.3/fpm/&lt;/code&gt; on Debian). Checking &lt;code&gt;php -i&lt;/code&gt; from the shell doesn't tell you what FPM loaded.&lt;/p&gt;

&lt;p&gt;Several CDNs and WAF vendors, including Cloudflare and Fastly, announced rules for PHP2Shell. That's a useful layer, but it only covers traffic that actually goes through them. Anything reachable directly by IP bypasses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check whether a server was already exploited?
&lt;/h2&gt;

&lt;p&gt;Wiz notes these bugs fire below the application layer, so application logs may show very little. The signals they recommend looking for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unusual multipart requests followed by memory-limit errors or repeated HTTP 500s&lt;/li&gt;
&lt;li&gt;Unexpectedly large or malformed &lt;code&gt;PHPSESSID&lt;/code&gt; cookies in responses&lt;/li&gt;
&lt;li&gt;Unexplained PHP worker churn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A first pass on a typical nginx + FPM box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s1"&gt;'Allowed memory size'&lt;/span&gt; /var/log/php&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-fpm&lt;/span&gt;.log /var/log/nginx/error.log 2&amp;gt;/dev/null | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s1"&gt;'exited on signal'&lt;/span&gt; /var/log/php&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-fpm&lt;/span&gt;.log 2&amp;gt;/dev/null | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9 == 500'&lt;/span&gt; /var/log/nginx/access.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first finds out-of-memory fatals, the second finds workers that crashed (segfaults are worth a closer look), and the third counts 500s in the current access log, assuming the default combined format. None of these proves exploitation on its own. A cluster of all three around the same multipart POSTs is what deserves an incident.&lt;/p&gt;

&lt;p&gt;If anything looks wrong, treat it as a compromise of everything that process could read: rotate database passwords, API keys and session secrets the app uses, and invalidate active sessions. The memory disclosure bug means secrets can leak without any code execution. Then check the host itself, including &lt;code&gt;authorized_keys&lt;/code&gt;, cron entries and new users, as you would after any RCE. The &lt;a href="https://termique.app/blog/complete-guide-ssh-security-2026/" rel="noopener noreferrer"&gt;complete guide to SSH security&lt;/a&gt; covers what to look at on the access side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where termique fits
&lt;/h2&gt;

&lt;p&gt;None of this needs special tooling, but it gets tedious past a handful of servers. If you manage your hosts in termique, a few things help. Save the version checks above as &lt;a href="https://docs.termique.app/snippets" rel="noopener noreferrer"&gt;snippets&lt;/a&gt; and run them in each session instead of retyping them. Snippets sync as plaintext, which is fine for commands like these but not for anything containing a secret. On Pro, incident investigation can run read-only commands across several selected hosts and assemble the results into a report, which suits the log checks in the last section; anything that changes the system still asks you first. And on shared hosts with command logging turned on, you get a record of who ran the patch and when, which is the question someone will ask later. More on that in &lt;a href="https://termique.app/blog/per-command-audit-logs-shared-ssh-access/" rel="noopener noreferrer"&gt;what a per-command audit log actually catches&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is updating WordPress, Drupal or Laravel enough?
&lt;/h3&gt;

&lt;p&gt;No. The bugs are in PHP itself, not in the CMS or framework. You need a fixed PHP build, and every worker running it, regardless of what the application is.&lt;/p&gt;

&lt;h3&gt;
  
  
  I'm on PHP 8.1 or older. What now?
&lt;/h3&gt;

&lt;p&gt;There's no upstream release for 8.1 and older. Check whether your distribution or LTS vendor shipped a backport (the changelog check above), build from the PHP team's source backports for 7.4 to 8.1, or use this as the push to move to a supported version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;php -v&lt;/code&gt; tell me if I'm patched?
&lt;/h3&gt;

&lt;p&gt;Only for the CLI, and only if you're on upstream version numbers. Check the running FPM, CGI or Apache module binary, and on distro packages check the changelog for the CVE IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a WAF or CDN enough protection?
&lt;/h3&gt;

&lt;p&gt;It reduces exposure for traffic routed through it. It does not protect a server that's reachable directly, and the PHP team's position is that updating is the only complete fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to rotate secrets even if I patched quickly?
&lt;/h3&gt;

&lt;p&gt;If your servers were exposed and you see the log signals above, yes. The memory disclosure path can leak credentials without leaving the kind of trace an RCE would. If logs are clean and you patched early, rotation is a judgment call; for internet-facing apps that handle payments or auth, it's cheap insurance.&lt;/p&gt;

&lt;p&gt;If you're juggling a lot of servers this week, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;termique&lt;/a&gt; is free to try.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>termique v0.7.0 – a redesigned workspace</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:30:40 +0000</pubDate>
      <link>https://dev.to/masitings/termique-v070-a-redesigned-workspace-fd5</link>
      <guid>https://dev.to/masitings/termique-v070-a-redesigned-workspace-fd5</guid>
      <description>&lt;p&gt;termique v0.7.0 is a big one. The whole workspace is rebuilt around one content panel, the investigation page is redesigned, and underneath that sits a long list of reliability, privacy and accessibility fixes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Update from the in-app updater when the prompt appears, or grab a fresh build from the &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;download page&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A redesigned workspace.&lt;/strong&gt; A top bar and an Explorer sidebar now frame a single content panel, replacing the old sidebar and tab strip. Empty and success states get new illustrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calmer lists.&lt;/strong&gt; Each list's views now sit in one dropdown next to its title instead of a row of controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring at a glance.&lt;/strong&gt; The monitoring detail view shows live resources as one strip instead of donut charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A redesigned investigation page.&lt;/strong&gt; Commands are grouped into one step list you can skip, edit or run inline, next to a case file that shows the status, the hosts involved and the report verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investigations always reach their report.&lt;/strong&gt; A failed turn can be retried without retyping it, and a step that doesn't name a host asks which host to run on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command logging is off by default for new hosts.&lt;/strong&gt; The share dialog offers to turn it on when it's off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover from a changed host key.&lt;/strong&gt; When a host's key has changed, you can forget the old key and reconnect, behind a clear warning and a confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard resizing.&lt;/strong&gt; Split panes, the Files view and the assistant panel can be resized from the keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller minimum window size&lt;/strong&gt;, so the app fits small laptop screens and scaled displays.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Command logging is now a choice you make per host. Hosts you already have keep their current setting, and when you share a host without logging, the share dialog tells you and lets you turn it on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A changed host key is either a server rebuild or something you should stop and look at. The new flow doesn't skip that decision, it just stops you from having to edit &lt;code&gt;known_hosts&lt;/code&gt; by hand once you've made it. If you're not sure which case you're in, compare the new fingerprint with the server's before you confirm. &lt;a href="https://termique.app/blog/ssh-host-key-verification-explained/" rel="noopener noreferrer"&gt;Host key verification explained&lt;/a&gt; walks through how to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Improvements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The master password lockout can no longer be reset by restarting the app.&lt;/li&gt;
&lt;li&gt;Commands proposed by the assistant are checked more strictly before they run automatically.&lt;/li&gt;
&lt;li&gt;The AI assistant no longer receives your hosts' addresses or ports, only each host's label and user.&lt;/li&gt;
&lt;li&gt;Share recipients are looked up without putting their email address in a URL.&lt;/li&gt;
&lt;li&gt;Assistant messages no longer load remote images.&lt;/li&gt;
&lt;li&gt;Credentials are removed from command logs before upload.&lt;/li&gt;
&lt;li&gt;Error reports no longer contain IP addresses, host names, email addresses or tokens.&lt;/li&gt;
&lt;li&gt;Faster startup: each page now loads the first time you open it, and charts and message formatting load only when needed.&lt;/li&gt;
&lt;li&gt;An upgraded SSH engine that fixes several security issues.&lt;/li&gt;
&lt;li&gt;If your system's web view is too old to draw the new interface, the app now tells you what to update.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Your server addresses now stay on your machine when you use the assistant. It only sees the name you gave a host and the user you log in as.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two of these are worth calling out. Labels are often less sensitive than addresses, so if your host labels contain internal hostnames or IPs, the assistant will still see those; rename them if that matters to you. And command logs are stored on termique's backend without end-to-end encryption, which is why stripping credentials before upload, plus logging being opt-in for new hosts, matters. If you're deciding whether to turn logging on for a shared host, &lt;a href="https://termique.app/blog/per-command-audit-logs-shared-ssh-access/" rel="noopener noreferrer"&gt;what a per-command audit log actually catches&lt;/a&gt; covers the tradeoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fixed SSH sessions hanging or blocking each other when one channel is slow, and connections left open after a failed connect.&lt;/li&gt;
&lt;li&gt;Fixed shared hosts disappearing after a background refresh, and save conflicts when editing a host.&lt;/li&gt;
&lt;li&gt;Fixed terminal scrollback being lost when tabs are merged or the app locks.&lt;/li&gt;
&lt;li&gt;Commands that the assistant or an investigation runs on a shared host now appear in the owner's activity log.&lt;/li&gt;
&lt;li&gt;Fixed the window freezing during database, keychain and file operations.&lt;/li&gt;
&lt;li&gt;Rounded window corners now stay when the window is maximized on macOS.&lt;/li&gt;
&lt;li&gt;The command palette, Explorer controls and previously unlabelled buttons now work properly with the keyboard and screen readers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Update to v0.7.0 to get the new workspace and these fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/termique-v0-6-5/" rel="noopener noreferrer"&gt;termique v0.6.5 – jump host support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/ai-coding-agents-ssh-command-approval/" rel="noopener noreferrer"&gt;AI coding agents over SSH: who approves the commands they run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://termique.app/blog/per-command-audit-logs-shared-ssh-access/" rel="noopener noreferrer"&gt;What a per-command audit log actually catches&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>ssh-keygen Best Practices for 2026: Keys, Rounds, FIDO</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:17:38 +0000</pubDate>
      <link>https://dev.to/masitings/ssh-keygen-best-practices-for-2026-keys-rounds-fido-15nk</link>
      <guid>https://dev.to/masitings/ssh-keygen-best-practices-for-2026-keys-rounds-fido-15nk</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick Summary: What You'll Learn&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The one &lt;code&gt;ssh-keygen&lt;/code&gt; command most people should run in 2026, and what each flag in it does.&lt;/li&gt;
&lt;li&gt;How to choose between Ed25519, RSA, FIDO-backed &lt;code&gt;-sk&lt;/code&gt; keys and the new post-quantum hybrid &lt;code&gt;mldsa44-ed25519&lt;/code&gt; type that OpenSSH 10.6 enabled.&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;-a&lt;/code&gt; (KDF rounds), a real comment and a passphrase matter more than key size.&lt;/li&gt;
&lt;li&gt;The file permissions, &lt;code&gt;~/.ssh/config&lt;/code&gt; lines and fingerprint checks that keep a good key from becoming a bad habit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most ssh-keygen best practices guides stop at "use Ed25519". That's still the right default in 2026, but it's only one decision out of five. The key type, the passphrase, the KDF rounds that protect that passphrase, the comment that tells you which laptop the key came from, and where the private key ends up afterwards all decide whether a stolen file is a non-event or an incident. OpenSSH also shipped changes this year that affect new keys, so some older advice is out of date. Every flag below is checked against the current OpenBSD &lt;code&gt;ssh-keygen(1)&lt;/code&gt; manual and the OpenSSH release notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the best ssh-keygen command to run in 2026?
&lt;/h2&gt;

&lt;p&gt;For a personal key on a laptop or workstation, this is a sane default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-a&lt;/span&gt; 100 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"you@laptop-2026-10"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t ed25519&lt;/code&gt; picks the key type. Since OpenSSH 9.5 (October 2023), Ed25519 is what &lt;code&gt;ssh-keygen&lt;/code&gt; generates when you pass no &lt;code&gt;-t&lt;/code&gt; at all, so this flag is mostly there to be explicit in scripts and docs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-a 100&lt;/code&gt; sets the number of bcrypt_pbkdf rounds used to encrypt the private key with your passphrase. The manual's default is 32. Higher means slower passphrase checks and slower offline cracking if the file is stolen. More on choosing a number below.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-C "you@laptop-2026-10"&lt;/code&gt; sets the comment. The default is &lt;code&gt;user@host&lt;/code&gt;, which tells you almost nothing two years later. Put the device and the date in it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-f ~/.ssh/id_ed25519_laptop&lt;/code&gt; names the file, so the key doesn't silently overwrite an existing &lt;code&gt;id_ed25519&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When it prompts for a passphrase, give it one. The manual's own guidance is that good passphrases are 10 to 30 characters, not simple sentences, with mixed character classes. A random phrase from a password manager is easier than inventing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which SSH key algorithm should you choose?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh-keygen -t&lt;/code&gt; accepts &lt;code&gt;ecdsa&lt;/code&gt;, &lt;code&gt;ecdsa-sk&lt;/code&gt;, &lt;code&gt;ed25519&lt;/code&gt;, &lt;code&gt;ed25519-sk&lt;/code&gt;, &lt;code&gt;mldsa44-ed25519&lt;/code&gt; and &lt;code&gt;rsa&lt;/code&gt; in the current manual. In practice, the choice looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ed25519&lt;/strong&gt;: the default for new keys. Small public keys, fixed length (&lt;code&gt;-b&lt;/code&gt; is ignored), and supported by OpenSSH since 6.5 (January 2014). Pick this unless something below applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSA&lt;/strong&gt;: only for systems that can't do Ed25519 (some network gear, older appliances, a few hosted services). The manual's default size is 3072 bits and it calls 3072 "generally sufficient". Many teams use &lt;code&gt;-b 4096&lt;/code&gt; anyway; that's fine, just slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ECDSA&lt;/strong&gt;: no strong reason to choose it over Ed25519 for user keys today. &lt;code&gt;-b&lt;/code&gt; selects the curve (256, 384 or 521); other values fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ed25519-SK / ECDSA-SK&lt;/strong&gt;: keys backed by a FIDO authenticator (a hardware security key). The private part never leaves the hardware. Best choice when you have the token and your servers run a modern OpenSSH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mldsa44-ed25519&lt;/strong&gt;: a hybrid signature that combines ML-DSA-44 (a post-quantum scheme) with Ed25519. Read the next section before adopting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing people still mix up: OpenSSH 8.8 disabled the old &lt;code&gt;ssh-rsa&lt;/code&gt; &lt;em&gt;signature algorithm&lt;/em&gt; (RSA with SHA-1) by default. That did not make RSA &lt;em&gt;keys&lt;/em&gt; obsolete. The same RSA key signs with &lt;code&gt;rsa-sha2-256&lt;/code&gt; or &lt;code&gt;rsa-sha2-512&lt;/code&gt;, and the release notes say exactly that. If an old RSA key stopped working against a new server, the usual culprit is a client or server too old to negotiate SHA-2 signatures, not the key itself.&lt;/p&gt;

&lt;p&gt;To see which key types your local OpenSSH supports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-V&lt;/span&gt;
ssh &lt;span class="nt"&gt;-Q&lt;/span&gt; key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Is the post-quantum mldsa44-ed25519 key ready to use?
&lt;/h3&gt;

&lt;p&gt;OpenSSH 10.4 (July 2026) added &lt;code&gt;mldsa44-ed25519&lt;/code&gt; as an experimental, off-by-default composite signature scheme. OpenSSH 10.6 (released 2026-10-06) enabled it as a standard algorithm and dropped the &lt;code&gt;@openssh.com&lt;/code&gt; suffix the experimental version used. The release notes are explicit that keys generated with the experimental support must be regenerated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; mldsa44-ed25519 &lt;span class="nt"&gt;-a&lt;/span&gt; 100 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"you@laptop-2026-10-pq"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_mldsa44_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Practical guidance for now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both ends need an OpenSSH release that understands the new algorithm name. A server on an older release won't accept the key, so check with &lt;code&gt;ssh -Q key&lt;/code&gt; on the server side too.&lt;/li&gt;
&lt;li&gt;If you generated one of these during the experimental 10.4/10.5 window, regenerate it on 10.6 rather than debugging why it stopped matching.&lt;/li&gt;
&lt;li&gt;Keep an Ed25519 key alongside it. Post-quantum key exchange already protects session traffic against record-now-decrypt-later attacks in recent OpenSSH releases; the signature side matters for a future attacker forging authentication, which is a slower-moving risk. There's no rush to make the hybrid key your only key.&lt;/li&gt;
&lt;li&gt;Third-party clients, libraries and hosted git services may not parse it yet. Test before you depend on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How many -a rounds should you use?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;-a&lt;/code&gt; value only matters for keys that have a passphrase. It controls how much work it takes to test one passphrase guess against a stolen private key file. The man page states the default (32) and that higher numbers mean slower verification and more resistance to brute force; it doesn't prescribe a value.&lt;/p&gt;

&lt;p&gt;A workable way to pick one: generate a throwaway key with a candidate value, then time how long unlocking it takes on your slowest machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-a&lt;/span&gt; 100 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s1"&gt;'test-only-passphrase'&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/kdf-test &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s1"&gt;'test-only-passphrase'&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/kdf-test &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/kdf-test /tmp/kdf-test.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-y&lt;/code&gt; reads the private key and prints the public key, so it has to decrypt the file first. If it takes a fraction of a second, you can go higher. If it's annoying on every connection, an agent (below) means you only pay it once per session. Don't type real passphrases on the command line like this; &lt;code&gt;-N&lt;/code&gt; and &lt;code&gt;-P&lt;/code&gt; end up in shell history. It's fine for a throwaway test key only.&lt;/p&gt;

&lt;p&gt;You don't need a new key to raise the rounds on an existing one. Changing the passphrase re-encrypts the file and accepts a new &lt;code&gt;-a&lt;/code&gt; value (you can keep the same passphrase):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; 100 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Should every SSH key have a passphrase?
&lt;/h2&gt;

&lt;p&gt;For keys that live on a laptop or a phone, yes. A passphrase turns "someone copied my &lt;code&gt;~/.ssh&lt;/code&gt; folder" from an immediate compromise into "they have an encrypted file and a lot of guessing ahead of them". The manual is blunt about the downside: there is no way to recover a lost passphrase. If you lose it, you generate a new key and replace the public key everywhere. Store it in your password manager.&lt;/p&gt;

&lt;p&gt;The exceptions are keys that a machine has to use unattended: host keys (the manual says these must have an empty passphrase), and automation keys on CI runners or deploy boxes. For those, compensate on the server side instead. Restrict what the key can do in &lt;code&gt;authorized_keys&lt;/code&gt; (a &lt;code&gt;from=&lt;/code&gt; source address and a forced command, for example), or use short-lived certificates so a leaked key expires on its own. Our comparison of &lt;a href="https://termique.app/blog/ssh-certificate-authority-vs-public-key-auth-teams/" rel="noopener noreferrer"&gt;SSH certificates vs plain public keys&lt;/a&gt; covers when certificates are worth the setup.&lt;/p&gt;

&lt;p&gt;To stop typing the passphrase on every connection, let &lt;code&gt;ssh-agent&lt;/code&gt; hold the decrypted key for the session. In &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; *
    &lt;span class="k"&gt;AddKeysToAgent&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;AddKeysToAgent yes&lt;/code&gt;, the key and passphrase are added to a running agent the first time the key is loaded from a file. If you hit "incorrect passphrase supplied to decrypt private key" along the way, our &lt;a href="https://termique.app/blog/ssh-key-passphrase-not-working/" rel="noopener noreferrer"&gt;passphrase troubleshooting guide&lt;/a&gt; walks through the usual causes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use a FIDO hardware key with ssh-keygen?
&lt;/h2&gt;

&lt;p&gt;When the key protects something you'd hate to lose, and you already carry a security key. With &lt;code&gt;ed25519-sk&lt;/code&gt;, the file in &lt;code&gt;~/.ssh&lt;/code&gt; is only a key handle. The actual private key sits inside the authenticator and can't be exported, so copying the file without the hardware gets an attacker nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519-sk &lt;span class="nt"&gt;-O&lt;/span&gt; resident &lt;span class="nt"&gt;-O&lt;/span&gt; verify-required &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"you@yubikey-2026-10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-O resident&lt;/code&gt; stores the key handle on the authenticator itself, so you can load it on another computer with &lt;code&gt;ssh-keygen -K&lt;/code&gt; (or &lt;code&gt;ssh-add -K&lt;/code&gt;). The manual notes the tradeoff: storing both parts on the device makes a stolen authenticator more useful to an attacker. Resident keys typically require a PIN on the token.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-O verify-required&lt;/code&gt; requires user verification (currently a PIN) for each signature, not just a touch. Not every authenticator supports it.&lt;/li&gt;
&lt;li&gt;By default every signature needs a physical touch. &lt;code&gt;-O no-touch-required&lt;/code&gt; turns that off, and &lt;code&gt;sshd&lt;/code&gt; refuses such signatures unless the &lt;code&gt;authorized_keys&lt;/code&gt; entry explicitly allows it. Leave the touch requirement on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The caveats: the server needs a modern OpenSSH to accept &lt;code&gt;-sk&lt;/code&gt; keys, and you should register a second authenticator as a backup, because losing the only token locks you out the same way a lost passphrase does.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you name, store and check SSH keys?
&lt;/h2&gt;

&lt;p&gt;Generating the key well is half of it. These habits keep it manageable later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One key per device.&lt;/strong&gt; If a laptop is lost, you revoke one public key instead of rotating a key that lives on four machines. The comment is how you tell them apart in &lt;code&gt;authorized_keys&lt;/code&gt;, which is why &lt;code&gt;-C "you@laptop-2026-10"&lt;/code&gt; beats the default. Our guide to &lt;a href="https://termique.app/blog/managing-multiple-ssh-keys-across-devices/" rel="noopener noreferrer"&gt;managing multiple SSH keys across devices&lt;/a&gt; has a naming scheme that scales past three machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix a bad comment without regenerating:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"you@laptop-2026-10"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Permissions.&lt;/strong&gt; &lt;code&gt;ssh(1)&lt;/code&gt; ignores a private key file that other users can read, and recommends that &lt;code&gt;~/.ssh&lt;/code&gt; itself is accessible only by you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/id_ed25519_laptop
&lt;span class="nb"&gt;chmod &lt;/span&gt;644 ~/.ssh/id_ed25519_laptop.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Point each host at the right key.&lt;/strong&gt; Without this, &lt;code&gt;ssh&lt;/code&gt; offers every key in your agent in turn, which can trip a server's &lt;code&gt;MaxAuthTries&lt;/code&gt; before it reaches the right one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; prod-bastion
    &lt;span class="k"&gt;HostName&lt;/span&gt; bastion.example.com
    &lt;span class="k"&gt;User&lt;/span&gt; deploy
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519_laptop
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; tells &lt;code&gt;ssh&lt;/code&gt; to use only the configured identity files, even when the agent holds others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install the public key, and only that one:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519_laptop.pub deploy@bastion.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-i&lt;/code&gt; makes sure only the key you named gets installed, with the comment from the &lt;code&gt;.pub&lt;/code&gt; file, rather than everything in your agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check fingerprints.&lt;/strong&gt; When you add a key to a server or a git host, compare fingerprints instead of eyeballing base64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-lf&lt;/span&gt; ~/.ssh/id_ed25519_laptop.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SHA-256 is the default fingerprint hash. If you lost the &lt;code&gt;.pub&lt;/code&gt; file, regenerate it from the private key with &lt;code&gt;ssh-keygen -y -f ~/.ssh/id_ed25519_laptop &amp;gt; ~/.ssh/id_ed25519_laptop.pub&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan the rotation now.&lt;/strong&gt; Put the year in the comment and you'll know which keys are overdue. Our &lt;a href="https://termique.app/blog/ssh-key-rotation-best-practices/" rel="noopener noreferrer"&gt;SSH key rotation best practices&lt;/a&gt; post covers how often to rotate and how to do it without locking anyone out, and the &lt;a href="https://termique.app/blog/ssh-key-management-for-teams/" rel="noopener noreferrer"&gt;team key management guide&lt;/a&gt; covers offboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ssh-keygen advice is outdated in 2026?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Generate RSA 2048."&lt;/strong&gt; Still accepted by &lt;code&gt;sshd&lt;/code&gt; (the &lt;code&gt;RequiredRSASize&lt;/code&gt; default is 1024 bits), but there's no reason to create new 2048-bit RSA keys when Ed25519 is available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"DSA is fine for old systems."&lt;/strong&gt; OpenSSH 10.0 removed DSA support entirely. Any remaining &lt;code&gt;id_dsa&lt;/code&gt; key needs replacing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"You must pass -t ed25519 or you get RSA."&lt;/strong&gt; Not since OpenSSH 9.5; Ed25519 is the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Use -o for the new key format."&lt;/strong&gt; The OpenSSH private key format has been the default for years, and current &lt;code&gt;ssh-keygen&lt;/code&gt; doesn't list &lt;code&gt;-o&lt;/code&gt; in its synopsis at all. Use &lt;code&gt;-m PEM&lt;/code&gt; only when an old tool insists on PEM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Post-quantum SSH keys aren't a thing yet."&lt;/strong&gt; As of OpenSSH 10.6 they are, as a hybrid. They're just early.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where does termique fit into SSH key management?
&lt;/h2&gt;

&lt;p&gt;termique is a free, native ssh manager we build, with end-to-end encrypted credentials. On the key side, it does a few specific things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generate or import.&lt;/strong&gt; You can generate &lt;code&gt;ed25519&lt;/code&gt; (recommended) or &lt;code&gt;rsa&lt;/code&gt; keys in Settings → SSH keys, or import an existing private key in OpenSSH or PEM format, including passphrase-protected keys. For FIDO &lt;code&gt;-sk&lt;/code&gt; keys and the new &lt;code&gt;mldsa44-ed25519&lt;/code&gt; type, generate with &lt;code&gt;ssh-keygen&lt;/code&gt; as shown above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted before it's stored.&lt;/strong&gt; Private keys are encrypted on your device with a key derived from your master password before they're written locally or synced. The sync backend stores ciphertext only. Hosts, groups and snippets sync as plaintext metadata, so don't put secrets in host names or snippets. The &lt;a href="https://termique.app/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt; has the full model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same keys on desktop and phone.&lt;/strong&gt; Keys sync across macOS, Windows, Linux and iOS, so the key you use from your laptop is available in the mobile app without emailing a private key to yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is a real tradeoff against the one-key-per-device advice above. A synced key is one key on several devices: convenient, and protected by end-to-end encryption, but a lost phone means rotating that key everywhere it's authorized. If your policy is strictly one key per device, generate a separate key on each device instead; termique handles both, and the comment you set still tells you which is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the most secure SSH key algorithm in 2026?
&lt;/h3&gt;

&lt;p&gt;For everyday use, Ed25519 backed by a FIDO authenticator (&lt;code&gt;ed25519-sk&lt;/code&gt;), because the private key can't be copied off the hardware. Without a hardware key, plain Ed25519 with a strong passphrase. The &lt;code&gt;mldsa44-ed25519&lt;/code&gt; hybrid adds post-quantum resistance to signatures, but needs OpenSSH 10.6 on both ends and isn't widely supported outside OpenSSH yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is RSA 4096 better than Ed25519?
&lt;/h3&gt;

&lt;p&gt;Not in any way that matters for most users. RSA 4096 is secure, but keys and signatures are much larger and slower. The manual considers 3072-bit RSA sufficient. Use RSA only for systems that can't handle Ed25519.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add a passphrase to an existing SSH key?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;ssh-keygen -p -f ~/.ssh/your_key&lt;/code&gt;. It prompts for the old passphrase (press Enter if there is none) and the new one twice. Add &lt;code&gt;-a 100&lt;/code&gt; to raise the KDF rounds at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use the same SSH key on every computer?
&lt;/h3&gt;

&lt;p&gt;Usually not. One key per device means a lost laptop costs you one revocation instead of a rotation across every server. Syncing one key across devices with end-to-end encryption is a valid choice for convenience, as long as you accept that losing any of those devices means rotating it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the -C flag do in ssh-keygen?
&lt;/h3&gt;

&lt;p&gt;It sets the key's comment, which is stored in the private key file and appended to the public key line in &lt;code&gt;authorized_keys&lt;/code&gt;. It has no security effect, but a comment like &lt;code&gt;you@laptop-2026-10&lt;/code&gt; tells you which device and year a key belongs to when you audit a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try termique free
&lt;/h2&gt;

&lt;p&gt;If you want your keys, hosts and snippets on every machine you use, with private keys end-to-end encrypted before they leave the device, &lt;a href="https://termique.app/download/" rel="noopener noreferrer"&gt;download termique&lt;/a&gt; and start on the free plan.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Alright my first weekly challenge on Hacktoberfest!</title>
      <dc:creator>Rafi Bagaskara Halilintar</dc:creator>
      <pubDate>Fri, 02 Oct 2026 20:20:47 +0000</pubDate>
      <link>https://dev.to/masitings/alright-my-first-weekly-challenge-on-hacktoberfest-n2c</link>
      <guid>https://dev.to/masitings/alright-my-first-weekly-challenge-on-hacktoberfest-n2c</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/masitings/kas-rumah-a-receipt-reading-expense-tracker-for-my-wife-that-never-leaves-our-laptop-28jo" class="crayons-story__hidden-navigation-link"&gt;Kas Rumah: a receipt-reading expense tracker for my wife that never leaves our laptop&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/masitings" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F355442%2F84af0b9d-2cc7-4f18-8712-3be8a9e0e873.png" alt="masitings profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/masitings" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Rafi Bagaskara Halilintar
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Rafi Bagaskara Halilintar
                
                
              
              &lt;div id="story-author-preview-content-4788965" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/masitings" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F355442%2F84af0b9d-2cc7-4f18-8712-3be8a9e0e873.png" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Rafi Bagaskara Halilintar&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/masitings/kas-rumah-a-receipt-reading-expense-tracker-for-my-wife-that-never-leaves-our-laptop-28jo" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 2&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/masitings/kas-rumah-a-receipt-reading-expense-tracker-for-my-wife-that-never-leaves-our-laptop-28jo" id="article-link-4788965"&gt;
          Kas Rumah: a receipt-reading expense tracker for my wife that never leaves our laptop
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/weekendchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;weekendchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/hf26challenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;hf26challenge&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/masitings/kas-rumah-a-receipt-reading-expense-tracker-for-my-wife-that-never-leaves-our-laptop-28jo#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>devchallenge</category>
      <category>opensource</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
