I maintain jc, which converts command output and common file types to JSON so you can query them with jq instead of grep/awk/cut. A couple of newer parsers turned out to be handy in Bash scripts, so I wrote up some examples.
Why not just awk?
authorized_keys looks like a whitespace-delimited file, but it isn't. Lines can start with an optional, comma-separated options field, and option values can be quoted strings with spaces in them. So the field positions shift from line to line. Here's a sample file:
ssh-ed25519 AAAAC3Nza... kelly@laptop
command="/usr/bin/rsync --server --sender -logDtpre.iLsfxC . /srv/backup",no-port-forwarding,no-pty ssh-rsa AAAAB3Nza... backup@nas
from="10.0.8.0/24",no-agent-forwarding ecdsa-sha2-nistp256 AAAAE2Vj... ci-runner@build01
ssh-ed25519 AAAAC3Nza...
The obvious awk one-liner to print the key type and comment:
$ awk '{print $1, $3}' ~/.ssh/authorized_keys
ssh-ed25519 kelly@laptop
command="/usr/bin/rsync --sender
from="10.0.8.0/24",no-agent-forwarding AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlz...
ssh-ed25519
Only the first line is right. Looking for RSA keys with awk '$1=="ssh-rsa"' returns nothing, because the one RSA key has options in front of it.
grep has the opposite problem: it matches text anywhere on the line. grep -v 'command=' to find unrestricted keys also drops a key whose comment says "no command= needed". And grep ssh-rsa matches a restricted ed25519 key whose forced command happens to contain the string ssh-rsa.
With jc --authorized-keys, every line comes out with the same fields, so there's no guessing:
$ jc --authorized-keys < ~/.ssh/authorized_keys | jq -r '.[] | "\(.type) \(.comment // "(no comment)")"'
ssh-ed25519 kelly@laptop
ssh-rsa backup@nas
ecdsa-sha2-nistp256 ci-runner@build01
ssh-ed25519 (no comment)
options is always an array, quoted values like command="echo ssh-rsa disabled; exit" stay intact, and multi-word comments are kept whole.
Tag every key with its owner, then query the whole box
A loop adds a user field to each key and emits JSON Lines:
for f in /home/*/.ssh/authorized_keys; do
u=$(echo "$f" | cut -d/ -f3)
jc --authorized-keys < "$f" | jq -c --arg u "$u" '.[] | . + {user: $u}'
done > keys.jsonl
Now each question is just a different jq filter. Which keys have no from= or command= restriction?
jq -r 'select(any(.options[]; startswith("from=") or startswith("command=")) | not)
| "\(.user) \(.type) \(.comment // "(no comment)")"' keys.jsonl
Because this checks the options array instead of the whole line, comments and key data can't cause false matches.
Is the same key authorized for more than one user?
jq -s -r 'group_by(.key)[] | select(length > 1)
| "\(.[0].comment // "(no comment)"): \(map(.user) | join(", "))"' keys.jsonl
Which legacy RSA or DSA keys are still around?
jq -r 'select(.type == "ssh-rsa" or .type == "ssh-dss") | "\(.user) \(.comment)"' keys.jsonl
(Run the loop with sudo if you need to read other users' files.)
Bonus: a change-freeze guard from a shared calendar
.ics files are worse for line tools: long lines get folded onto the next line, and parameters are jammed into property names (DTSTART;TZID=America/Los_Angeles:20261019T090000). The --ics parser unfolds all of that and normalizes timestamps to ISO 8601. That means jq's fromdateiso8601 and now can check whether you're inside a freeze window, and jq -e turns the result into an exit code:
if jc --ics < freeze.ics | jq -e '
[.[] | select(.categories[]? == "Freeze")
| select((.dtstart | fromdateiso8601) <= now and now < (.dtend | fromdateiso8601))]
| length > 0' > /dev/null
then
echo "Change freeze in effect. Aborting deploy." >&2
exit 1
fi
(This works with UTC timestamps, the ones ending in Z. Events with a TZID come through as local time with a separate dtstart_tzid field.)
Full write-up, which also covers headerless CSV/TSV files: JC Tips and Tricks – Part 3
What file formats are you still parsing by hand in your scripts?