One Entra sign-in to a full Linux desktop: SSH certificates, Kerberos, and PKINIT
Getting from 'everyone types a Linux password' to 'one Entra ID sign-in in a browser, then a graphical desktop with no password anywhere.' The terminal half took an hour. The graphical half took the rest of the day and taught me considerably more.
- The easy half: SSH certificates from an Entra sign-in
- The wall: the X2Go client cannot present a certificate
- The reframe that unstuck it
- The gap that was left: the ticket came from a typed password
- Dogtag copies the SAN from the CSR, it does not construct it
- The undocumented part: the KDC wants the certificate attached, not just named
- Entra backdates its tokens, and step-ca doesn’t like it
- The certificate identity comes from the token, never from the command line
- PKINIT is mutual, and that’s a distribution job
- Client-side papercuts, each pointing at the wrong thing
- Bootstrapping trust when the thing you’re installing is the trust
- Honest limits
- What’s next
Real client work, anonymized.
example.com,10.0.0.x, and generic hostnames stand in for the real ones. Placeholder values are obviously placeholders.
The goal was one credential. A user signs in to Entra ID once, in a real browser, with whatever Conditional Access and MFA their tenant already enforces, and from there gets both a shell and a full graphical desktop on the Linux estate without typing a Linux password anywhere.
The terminal half fell out quickly. The graphical half took the rest of the day and is where all the interesting material is. I spent most of that day being wrong, and in every case what corrected me was a server log rather than reasoning. The tools kept reporting things adjacent to true: a connection failure that was a success, an authorization error that was a clock, a GSSAPI misconfiguration that was a valid ticket in the wrong place. The log was the only honest witness.
The easy half: SSH certificates from an Entra sign-in¶
Run a CA that trusts Entra, have it issue short-lived SSH certificates off a browser sign-in, and
put the CA’s public half in TrustedUserCAKeys on every host. I used step-ca with an OIDC
provisioner pointed at an Entra app registration. The only real decision was the OAuth flow.
Why authorization code with PKCE, not the device-code flow
Nearly every walkthrough of OIDC-to-CLI reaches for the device-code flow, because it’s the one designed for inputs that can’t host a browser. But this input can host a browser. The device-code flow puts a URL and a short code on a text prompt and asks the human to carry them to another window, every time they need a credential. Authorization code with PKCE opens the browser for them, catches the redirect on a loopback listener, and hands back a token. Same security properties, one less step, and the user lands on a real Microsoft sign-in page rather than typing a code into one. Reserve the device-code flow for the case it was invented for: a device that genuinely cannot open a browser.
Issuance is one command, and the certificate lands in the agent:
step ssh login "[email protected]" --provisioner entra
ssh compute01.example.com
The certificate is short-lived, so there’s nothing to clean up. The part I didn’t expect to value
as much as I do is the key ID: step-ca stamps the Entra UPN into it, and sshd logs it on every
accepted connection.
$ journalctl -u sshd --since "4 min ago" --no-pager | grep -i alice | tail
Sep 16 14:36:29 compute01.example.com sshd[586383]: Accepted publickey for alice from 10.0.0.150 port 54325 ssh2: ECDSA-CERT SHA256:EAB7+lUnnGFG… ID [email protected] (serial 17047800644787914156) CA ECDSA SHA256:rmFqpDuDx++x…
Sep 16 14:36:29 compute01.example.com sshd[586383]: pam_unix(sshd:session): session opened for user alice by (uid=0)
Sep 16 14:36:30 compute01.example.com sshd[586383]: pam_unix(sshd:session): session closed for user alice
ECDSA-CERT rather than ECDSA, an ID field carrying the Entra UPN, a serial, and the CA that
signed it. With static authorized_keys that line gives you a key fingerprint and nothing else,
and mapping it back to a human means hoping the comment field was maintained. Here the log names
the Entra identity, because the CA wrote it there at issuance. Terminal access took about an hour.
The wall: the X2Go client cannot present a certificate¶
Graphical access here is X2Go, which is NX over an SSH transport. I assumed that because X2Go tunnels over SSH, and SSH now accepts certificates, X2Go would accept certificates. It does not.
I tried both shapes. Agent-based first, with a valid certificate loaded and ssh succeeding from
the same shell seconds earlier. Then the file-based shape, pointing the client at the private key
with the -cert.pub companion beside it, the convention OpenSSH itself uses. The client’s error
was unhelpful, so I stopped reading it and watched the server. This is the agent attempt, with the
two successful logins that preceded it left in as controls:
$ journalctl -u sshd --since "4 min ago" --no-pager | grep -iE "alice|bob" | tail -25
Sep 16 14:36:29 compute01.example.com sshd[586383]: Accepted publickey for alice from 10.0.0.150 port 54325 ssh2: ECDSA-CERT SHA256:EAB7+lUnnGFG… ID [email protected] (serial 17047800644787914156) CA ECDSA SHA256:rmFqpDuDx++x…
Sep 16 14:37:42 compute01.example.com sshd[590233]: Accepted publickey for bob from 10.0.0.150 port 54345 ssh2: ED25519 SHA256:g6NBBqHmzbTZ…
Sep 16 14:39:07 compute01.example.com sshd[595786]: Postponed keyboard-interactive for alice from 10.0.0.150 port 54359 ssh2 [preauth]
Sep 16 14:39:07 compute01.example.com sshd[595797]: pam_sss(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=10.0.0.150 user=alice
Sep 16 14:39:07 compute01.example.com sshd[595797]: pam_sss(sshd:auth): received for user alice: 7 (Authentication failure)
Sep 16 14:39:08 compute01.example.com sshd[595786]: error: PAM: Authentication failure for alice from 10.0.0.150
Sep 16 14:39:14 compute01.example.com sshd[595786]: Disconnected from authenticating user alice 10.0.0.150 port 54359 [preauth]
[two further identical cycles at 14:39:19 and 14:39:40 trimmed]
The file-based attempt, captured about four minutes later, is the same story:
$ journalctl -u sshd --since "4 min ago" --no-pager | grep -i alice | tail -12
Sep 16 14:43:27 compute01.example.com sshd[607860]: pam_sss(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=10.0.0.150 user=alice
Sep 16 14:43:27 compute01.example.com sshd[607860]: pam_sss(sshd:auth): received for user alice: 7 (Authentication failure)
Sep 16 14:43:30 compute01.example.com sshd[607679]: error: PAM: Authentication failure for alice from 10.0.0.150
Sep 16 14:43:43 compute01.example.com sshd[607679]: Disconnected from authenticating user alice 10.0.0.150 port 54406 [preauth]
[two further identical cycles at 14:43:59 and 14:44:15 trimmed]
Read what isn’t there. At 14:36 the same account authenticated with the same certificate from the
same machine, and sshd logged ECDSA-CERT with the key ID. At 14:37 a second test account with an
ordinary key in authorized_keys was accepted too, so the host, the transport and public-key auth
were all healthy. Then at 14:39, and again at 14:43, there is no publickey line for alice at
all. Not a rejected certificate, not a bad signature. sshd went straight to
Postponed keyboard-interactive, which is what it does when the client offers no public key worth
trying, and pam_sss was asked to check a password for an account that deliberately has none.
So the failure that presents as “authentication failed” is really the client silently declining to offer the credential and falling back to asking for a password. It parses a key file, doesn’t implement the certificate extension, and has no path to the agent’s certificate either. That isn’t a misconfiguration, it’s a feature the client doesn’t have.
Prove a negative from the server side
A client that says ‘authentication failed’ has told you almost nothing. It might have offered a credential that was rejected, offered the wrong one, or offered nothing at all, and those have completely different fixes. The server log separates them for free. I burned several attempts re-checking file permissions and agent state on a problem sshd settled in one line. When a client and a server disagree about what happened, the server is the witness. Read its log before you change anything on the client.
The reframe that unstuck it¶
I’d been treating “SSH certificates” as the requirement. It never was. The requirement was “a credential that isn’t a password,” and certificates are only one way to satisfy that. Three families can carry a passwordless login, with genuinely different properties:
| SSH certificate | Kerberos ticket | Plain SSH key | |
|---|---|---|---|
| X2Go client can present it | No | Yes, via GSSAPI | Yes |
Covers sudo without a password |
No, categorically | Yes, via pam_sss_gss |
No |
| Works with no KDC reachable | Yes | No | Yes |
| Identity in the server log | Yes, from the key ID | Yes, the principal | Fingerprint only |
| Expiry does the revocation work | Yes, minutes | Yes, hours | No, keys live forever |
| Per-host distribution burden | CA public key, once | Already there for the directory | Every key to every host |
The client speaks Kerberos. Every compute host already had GSSAPI enabled, because they’re all
FreeIPA clients and have been for years. The infrastructure I needed was already running. Ticking
one checkbox in the X2Go client gave a full graphical desktop, on hosts with
PasswordAuthentication no, with the client never seeing a password.
Kerberos also does something certificates categorically cannot: it covers sudo. A certificate
authenticates an SSH connection and is then finished, which is why the previous part’s honest
limits included “your users will hit a sudo prompt for a password they no longer have.” A ticket
is a credential the session still holds, so pam_sss_gss can elevate against it. A gap I’d
flagged as future work closed itself as a side effect of solving something else.
Ask what the requirement is, not what the mechanism is
I lost real time because I’d promoted an implementation choice into a requirement without noticing. Once ‘we need SSH certificates’ became ‘we need a credential that isn’t a password,’ the answer was a checkbox in a client, against infrastructure already deployed and already monitored. When you hit a wall, restate the requirement one level more abstract and re-enumerate. If the restated version has three families of answer and you’ve tried one, you aren’t stuck, you’re narrow.
The gap that was left: the ticket came from a typed password¶
The goal was now mostly met, except for the part that mattered. The ticket making all of this work
came from kinit, and kinit asks for a password. I had moved where the password was typed
rather than removing it.
Closing that means PKINIT: presenting an X.509 certificate to the KDC and getting a Kerberos ticket back. FreeIPA supports it. The documentation covers the smartcard case thoroughly and the “certificate from an arbitrary CA” case barely at all, which is where the next few hours went.
Dogtag copies the SAN from the CSR, it does not construct it¶
A Kerberos principal in an X.509 SAN is an otherName with OID 1.3.6.1.5.2.2, wrapping a realm
and a principal name in a specific ASN.1 shape. It is not a field most CAs have heard of, and I
couldn’t establish from documentation whether I could make one emit it.
So I dumped FreeIPA’s own stock KDC PKINIT profile and read it. The load-bearing line is a
userExtensionDefaultImpl on OID 2.5.29.17, which is subjectAltName. That default does one
thing: it copies the extension from the CSR into the certificate. It doesn’t build a SAN or
validate the contents, it passes through whatever arrived.
So nobody has to teach a CA what a Kerberos principal is. You build the structure in the CSR with an OpenSSL config and the profile carries it through untouched:
[ req ]
default_bits = 3072
prompt = no
distinguished_name = dn
req_extensions = exts
[ dn ]
CN = alice
[ exts ]
subjectAltName = otherName:1.3.6.1.5.2.2;SEQUENCE:krb5princ
extendedKeyUsage = 1.3.6.1.5.2.3.4
[ krb5princ ]
realm = EXPLICIT:0,GeneralString:EXAMPLE.COM
principal = EXPLICIT:1,SEQUENCE:krb5princ_name
[ krb5princ_name ]
name_type = EXPLICIT:0,INTEGER:1
name_string = EXPLICIT:1,SEQUENCE:krb5princ_seq
[ krb5princ_seq ]
name = GeneralString:alice
1.3.6.1.5.2.3.4 is id-pkinit-KPClientAuth, the extended key usage the KDC requires before it
will accept a certificate as a client credential. INTEGER:1 is KRB5_NT_PRINCIPAL.
Now the trap. The obvious verification step lies to you:
$ openssl req -in alice.csr -noout -text | grep -A1 'Alternative'
X509v3 Subject Alternative Name:
othername: 1.3.6.1.5.2.2::<unsupported>
OpenSSL prints <unsupported> because its text printer can’t render this otherName type. It
prints exactly that whether the structure inside is correct or complete garbage, so it confirms
nothing. Use asn1parse instead, pointed at the SAN extension’s octet string:
$ OFF=$(openssl asn1parse -in alice.csr | grep -A1 'Subject Alternative Name' \
| tail -1 | cut -d: -f1 | tr -d ' ')
$ openssl asn1parse -in alice.csr -strparse "$OFF"
0:d=0 hl=2 l= 49 cons: SEQUENCE
2:d=1 hl=2 l= 47 cons: cont [ 0 ]
4:d=2 hl=2 l= 6 prim: OBJECT :1.3.6.1.5.2.2
12:d=2 hl=2 l= 37 cons: cont [ 0 ]
14:d=3 hl=2 l= 35 cons: SEQUENCE
16:d=4 hl=2 l= 13 cons: cont [ 0 ]
18:d=5 hl=2 l= 11 prim: GENERALSTRING
31:d=4 hl=2 l= 18 cons: cont [ 1 ]
33:d=5 hl=2 l= 16 cons: SEQUENCE
35:d=6 hl=2 l= 3 cons: cont [ 0 ]
37:d=7 hl=2 l= 1 prim: INTEGER :01
40:d=6 hl=2 l= 9 cons: cont [ 1 ]
42:d=7 hl=2 l= 7 cons: SEQUENCE
44:d=8 hl=2 l= 5 prim: GENERALSTRING
That’s the structure: the OID, then a realm GENERALSTRING of 11 bytes (EXAMPLE.COM), then
INTEGER :01 for KRB5_NT_PRINCIPAL, then a 5-byte GENERALSTRING for the principal (alice).
The nesting and the lengths are the check.
Note that asn1parse shows the shape but not the string contents, so read the lengths. If you
want to see the values, the extension’s hex dump has them in the clear:
$ openssl asn1parse -in alice.csr | grep -A1 'Subject Alternative Name' | tail -1
477:d=7 hl=2 l= 51 prim: OCTET STRING [HEX DUMP]:3031A02F06062B0601050202A0253023A00D1B0B4558414D504C452E434F4DA1123010A003020101A10930071B05616C696365
4558414D504C452E434F4D is EXAMPLE.COM and 616C696365 is alice. That’s the green light.
Ask whether a CA honours your SAN, not whether it can build one
I spent about two hours on the wrong question. I was evaluating CAs on whether they could generate a Kerberos principal SAN, which almost none can, and which made the whole approach look blocked. The right question is whether the CA will honour the SAN in a CSR it’s handed. That’s far more common, and it moves the work from ‘find a CA with an exotic feature’ to ‘write an OpenSSL config.’ When a CA seems unable to issue the certificate you need, check whether it will simply copy what you put in the request. Pass-through is much more widely implemented than construction.
The undocumented part: the KDC wants the certificate attached, not just named¶
With a correct principal SAN, PKINIT still failed. The KDC refused it with Certificate mismatch,
which surfaces as KDC_ERR_CLIENT_NAME_MISMATCH.
I read that as an identity-mapping problem and went to work on FreeIPA certmap rules, the documented mechanism for teaching the KDC how to derive a user from a certificate. I spent an hour there. It was the wrong hour, because certmap rules solve a problem I no longer had.
The distinction I’d missed, which I could not find stated anywhere:
- The
id-pkinit-sanremoves the need for a mapping rule. The KDC doesn’t have to work out who the certificate belongs to. The certificate says so. - It does not remove the need for the certificate to be associated with the account. The KDC still checks that this specific certificate is attached to that principal’s directory entry before trading it for a ticket.
Naming the principal and being trusted for the principal are different things, and satisfying the
first does nothing for the second. The knob is on the certificate profile. With store=FALSE the
issued certificate is never written back to the entry, and it’s refused:
$ kinit -X X509_user_identity=FILE:alice.crt,alice.key alice
kinit: Certificate mismatch while getting initial credentials
With store=TRUE, same profile, same user, same certificate contents, same SAN, same CA:
$ KRB5_CONFIG=~/pkinit/krb5.conf KRB5CCNAME=FILE:~/pkinit/ccache \
kinit -V -X X509_user_identity=FILE:~/pkinit/cert.pem,~/pkinit/key.pem \
[email protected]
Using default cache: ~/pkinit/ccache
Using principal: [email protected]
PA Option X509_user_identity = FILE:~/pkinit/cert.pem,~/pkinit/key.pem
Authenticated to Kerberos v5
$ klist
Ticket cache: FILE:~/pkinit/ccache
Default principal: [email protected]
Valid starting Expires Service principal
09/16/26 16:05:19 09/17/26 15:31:50 krbtgt/[email protected]
A TGT, from a certificate, with no password typed. That’s the green light. The only difference between the two runs is whether the CA wrote a copy of the certificate onto the directory entry.
store=TRUE has a real cost, and a real upside
Every issuance appends a certificate to the user’s directory entry. Issue short-lived certificates on an hourly cadence and that attribute grows without bound, so any service automating issuance has to prune as it issues. Budget for that before you pilot, not after.
The upside is worth naming. Because trust is an explicit association rather than an inference from the certificate’s contents, revocation gets a natural home: remove the certificate from the entry and the KDC stops accepting it immediately, independent of expiry and of CRL distribution. The pure short-lived-certificate design doesn’t give you that.
Entra backdates its tokens, and step-ca doesn’t like it¶
Entra issues tokens with iat and nbf backdated five minutes, deliberately, as skew tolerance
for relying parties whose clocks run slow. step-ca refuses tokens issued before its own process
start, as a replay guard. Together, every restart of the CA opens a five-minute window where every
genuinely fresh token looks stale and all issuance fails:
$ step ssh login "[email protected]" --provisioner entra
The request lacked necessary authorization to be completed. Please see the
certificate authority logs for more info.
That reads like a permissions problem, a provisioner problem, or a tenant problem. It is a clock problem, and nothing in the sentence is about time. The practical rule is dull and effective: batch provisioner changes into one restart, then wait five minutes before testing. Iterate faster than that and you’ll conclude your configuration is broken several times while it’s fine.
The certificate identity comes from the token, never from the command line¶
I asked for a certificate for one account and got a valid certificate for a different identity. Nothing in the output flagged it. The identity comes from the token, and the token comes from whichever Microsoft session the browser silently chose. I had several live: the tenant account, and a personal Microsoft account from something unrelated. Entra saw an existing session, didn’t prompt, and issued for that one. The account name on the command line was never an assertion about identity, it was at most a hint.
Anyone who has signed in to a personal Microsoft account in their work browser will hit this, which is most people. It’s confused-deputy-adjacent: the browser held authority I didn’t intend it to exercise, and exercised it without telling anyone. Two fixes, and use both:
# On the provisioner: only accept identities from the tenant's own domain
step ca provisioner update entra --domain example.com
# On the authorization request: always make the user choose
--auth-param prompt=select_account
The domain restriction makes a wrong identity fail closed rather than succeed quietly.
prompt=select_account makes the choice visible every time.
A credential that names the wrong person is worse than one that fails
A failed issuance is a ticket. A successful issuance for an unintended identity is a valid, correctly signed credential in circulation, which the logs will attribute to whoever the token said. Nothing downstream can detect it, because nothing downstream is wrong. Where a credential’s identity comes from an ambient session rather than an explicit statement, constrain what the session may assert and make the user confirm it.
PKINIT is mutual, and that’s a distribution job¶
PKINIT validates in both directions. The client checks the KDC’s certificate before handing over its own, so every laptop needs the IdM CA installed as a PKINIT anchor before any of this works from that laptop.
The SSH certificate design never had this problem, because validation there is one-way: the host checks the user’s certificate against a CA public key it already has, and the user’s machine validates nothing. The PKINIT version is small and tractable, but it’s a per-endpoint distribution task on every device in scope, which is a different category of work from a server-side config change. It was absent from my estimate.
Client-side papercuts, each pointing at the wrong thing¶
Four smaller problems, grouped because every error message named something other than the cause.
A successful connection reported as a failure. OpenSSH 10.x warns when the server offers no
post-quantum key exchange algorithm it still supports. The X2Go client parses ssh’s stderr and
treats any output as a failed connection, so a connection that worked is reported as broken,
with a message about cryptography that has nothing to do with anything.
$ ssh compute01.example.com
...
** The server may need to be upgraded. See https://openssh.com/pq.html
The fix is WarnWeakCrypto no in ssh_config. The server can’t fix it: its OpenSSH offers only
an obsolete PQ algorithm the modern client already dropped, so there’s no overlap to negotiate.
The warning is accurate; the client’s handling of it is not.
macOS DISPLAY isn’t a display string nxproxy understands. macOS sets it to a launchd path
like /var/run/com.apple.launchd.XXX/org.xquartz. nxproxy (still shipping as NXPROXY - Version
3.5.99.20, which tells you something) needs a plain :0.
So X2Go on macOS has to be launched from the tool that sets both variables. I tried to fix that
with launchctl setenv so a Dock launch would work, and backed it out. Modern macOS doesn’t
reliably propagate it to Finder-launched apps, so it didn’t fix the case I wanted, and a global
DISPLAY override would break other X11 apps whenever XQuartz wasn’t running. A fix that half
works and adds a new failure mode is worse than documenting the launch path.
Homebrew withdrew the X2Go cask because the macOS build isn’t notarised, leaving a Gatekeeper
dead end. Useful property: a file fetched with curl doesn’t get the com.apple.quarantine
attribute, because quarantine is applied by the downloading application rather than the
filesystem. Fetching an unsigned app that way avoids the dead end without disabling anything.
Bootstrapping trust when the thing you’re installing is the trust¶
Users run a setup script that installs the internal CA. They fetch it over TLS from a host whose certificate is issued by that CA. They can’t verify that connection, because trusting the CA is one of the things the script is going to do. The trust you’d normally bootstrap with is the trust you’re bootstrapping.
The resolution is curl -k plus the script’s SHA256 printed in the setup guide:
curl -k -O https://ca.example.com/setup.sh
shasum -a 256 setup.sh # compare against the value printed in the guide
The printed document is the trust anchor, and here it’s the stronger one
Skipping TLS verification feels like a downgrade and it isn’t, quite. The guide arrives through a channel the user already trusts, and the hash it carries is an out-of-band anchor that doesn’t depend on the CA being installed first. TLS here would verify a chain whose root is the thing we’re establishing, which is circular. The hash isn’t. When you’re distributing the root of trust itself, the anchor has to come through a channel that doesn’t depend on it.
It earned its keep within the hour, in a way I didn’t anticipate. Two scripts that should have differed hashed identically, which told me the service was serving stale code. I’d installed from the wrong filename, and the service was up, healthy, and confidently serving the old version. Nothing else in the stack noticed. The hash did, and I’d added it for a different reason entirely.
Honest limits¶
Certificates accumulate on directory entries. store=TRUE is required and appends on every
issuance. Pruning has to exist before this goes past a pilot, and it doesn’t yet.
The Windows client path is written but untested. It follows from the macOS work and I have no evidence for it.
Only one compute host is currently reachable over the mesh, and it isn’t the one most graphical users actually work on. That’s a network policy gap rather than an identity one, which makes it easy to fix and easy to forget. The identity work is done for hosts nobody can reach yet.
X2Go is barely maintained. The current Windows build is from 2020. The whole graphical story rests on it, so that’s worth saying plainly rather than burying. It works and has no obvious successor for this use case, but nobody should adopt it thinking it’s actively developed.
Passwords were never removed. Everything here is additive. Every host still accepts the old
path, kinit still takes a password, and rollback is “stop using the new thing.” Same discipline
as the cutover in Part 5, and the reason a day
of being repeatedly wrong cost nothing but the day.
What’s next¶
Pruning, because store=TRUE makes it mandatory rather than tidy, and an issuing service that
handles the whole cycle instead of me running step by hand. Then the network policy gap, so the
hosts people actually use are reachable over the mesh. Then a pilot with real users, which is
where I expect to learn that some of the above is wrong in ways no log has told me about yet.