← Back to Wiki
Desktop Apps / Authentication

Desktop Single Sign-On Should Go Through the Real Browser, and Come Back Through a URL Scheme

The quick way to add single sign-on to a desktop app is an embedded browser window. It is also the wrong way. That window has none of the user's sessions, none of their password manager, and it asks them to type a company password into a frame your application controls. RFC 8252 is the standing answer: open the system browser, and get the result back through a URL scheme you own.

Share on X

What the system browser gives you for free

Providers increasingly refuse embedded views outright for these reasons, so this is not only a best practice question.

The return trip

Register a scheme, send it as the redirect target, and handle it when the OS hands it back:

app.setAsDefaultProtocolClient('myapp')      // myapp://auth/callback?code=...

app.on('open-url',       (e, url) => handle(url))          // macOS
app.on('second-instance', (e, argv) => handle(argv.pop())) // Windows and Linux
BE WARNED: on Linux the scheme also has to be in the desktop entry, or the callback can never arrive. This one only surfaced while packaging for Flathub. Without MimeType=x-scheme-handler/myapp; in the .desktop file, the desktop environment has no idea your app owns that scheme, so the browser opens the URL and nothing happens. Desktop sign-in could not have completed under any Linux packaging, and none of it is visible in development, where the scheme is registered by whatever you last ran.

Take the single-instance lock, or you log yourself out

Without a lock, the protocol URL launches a second copy of your app. That copy receives the login, and the window the user was actually looking at sits there still logged out. It reads as "sign-in did nothing", and it is one line to prevent:

if (!app.requestSingleInstanceLock()) app.quit()

Never put the session token in the URL

This is the part worth being strict about. Redirecting with the session JWT in the query string writes a long-lived credential into browser history, into the referrer header, and into every proxy and access log along the way. It is trivial and it is permanent.

Hand back a short-lived, single-use code instead, store only its hash, and have the client POST it back to exchange for the real token:

myapp://auth/callback?code=<random>      # useless on its own, expires in 2 minutes
POST /auth/exchange { code }             # returns the session token in a body, not a URL

Configure it from the environment, not the database

The client secret is a deployment credential, the same kind of thing as your database URL. Putting it in a settings table means any read of instance settings, by any code path that ever gets it slightly wrong, is enough to walk off with it.

Keep the feature off unless issuer, client ID and client secret are all present, and expose that state so the client knows whether to draw the button:

GET /auth/oidc/config  →  {"enabled": false}

Then verify on the real deployment that it reports disabled before you configure it. An auth feature that is accidentally half-on is worse than one that is off.

Identity is the issuer and the subject, not the email

Two rules that prevent account takeover, and both are easy to get wrong:

Also decide deliberately whether the first account can be created this way. We refused, because otherwise whoever can authenticate at the identity provider becomes the owner of an unclaimed instance.

Verify the ID token yourself, and test the cases that must fail

Fetch the provider's JWKS and verify the signature, issuer, audience and expiry. This does not need a new dependency, and avoiding one is worth something when a dependency change has already taken your app down once.

The tests that matter are the ones asserting rejection:

- a tampered payload                       must fail
- alg: none                                must fail
- HS256 signed with the public key as HMAC must fail   (algorithm confusion)
- wrong issuer / wrong audience / expired  must fail

A test suite that only proves a valid token is accepted proves nothing about a token forger.

When this isn't your problem