Skip to content

Sign-In For Native, Desktop And CLI Apps

Desktop apps, CLIs and developer tools sign users in through the Seamless Auth OpenID Provider as public clients, following RFC 8252 (OAuth 2.0 for Native Apps). The app opens the user’s system browser on the hosted sign-in page, the user signs in there (with a passkey or any other enabled method), and the browser hands an authorization code back to the app through a loopback address or a private-use URI scheme. The app exchanges the code with PKCE. It never sees the user’s credentials and never holds a client secret.

Read Use Seamless Auth As An OpenID Provider first for the edge service, the registry and the token model. This page covers what is different for native apps.


  1. Register a public client. A native app cannot keep a secret: anything in the binary can be extracted. A public client authenticates with PKCE alone, has no secret and no keys, and cannot use client_credentials. The registry refuses any other combination.
  2. Use the system browser, never an embedded webview. A webview lets the app read what the user types, and the user cannot tell a real sign-in page from a copy. Open the authorization URL in the default browser (or ASWebAuthenticationSession on macOS and iOS, Custom Tabs on Android).
  3. Use PKCE with S256. Every client must. The edge refuses plain and requests without PKCE.
  4. Receive the code on a loopback redirect or a private-use scheme. See the next section.
  5. Keep tokens in the OS credential store: the macOS Keychain, Windows Credential Manager, or the Secret Service API (GNOME Keyring, KWallet) on Linux. Not a plain file, not an environment variable. See Keep Tokens In The OS Credential Store.

A public client registers its redirect URIs like any other application. The registry and the edge accept three forms:

Form Example Matching
IPv4 loopback http://127.0.0.1/callback Any port at request time. Scheme, host and path must match exactly.
IPv6 loopback http://[::1]/callback Any port at request time. Scheme, host and path must match exactly.
Private-use scheme com.example.desk:/callback Exact. The scheme must be in reverse domain form.
Claimed https URL https://desk.example.com/cb Exact, port included. For apps that claim links (universal or app links).

An application with a loopback or private-use redirect is reported as applicationType: native.

Loopback on any port (RFC 8252 section 7.3)

Section titled “Loopback on any port (RFC 8252 section 7.3)”

Register the loopback URI without a port. At sign-in, the app listens on whatever port the OS gives it and sends that port in redirect_uri:

registered: http://127.0.0.1/callback
requested: http://127.0.0.1:53124/callback accepted
requested: http://[::1]:61000/callback accepted if http://[::1]/callback is registered
requested: http://127.0.0.1:53124/other refused (path differs)
requested: https://127.0.0.1:53124/callback refused (scheme differs)
requested: http://127.0.0.2:53124/callback refused (host differs)
requested: http://localhost:53124/callback refused (localhost)

Bind the listener to the loopback interface only (127.0.0.1 or ::1), never to all interfaces. The port exception applies only to these loopback URIs. A web application’s https redirect never matches on a different port.

localhost is refused as a redirect host, at registration and at sign-in (RFC 8252 section 8.3). The name can resolve to an address other than the loopback interface, or to the IPv6 address when the app listens on IPv4. Use the literal 127.0.0.1 or [::1].

Private-use schemes (RFC 8252 section 7.1)

Section titled “Private-use schemes (RFC 8252 section 7.1)”

A private-use scheme must be in reverse domain form, a name with at least one dot that you control, such as com.example.desk:/callback. The dot keeps out javascript:, data: and every other scheme a browser acts on. Register the scheme with the OS so the browser hands the redirect to your app. Any app can claim the same scheme on most platforms, which is why PKCE is required: an app that intercepts the code cannot redeem it without the verifier.


A complete loopback sign-in for a Node CLI, with openid-client. Register the client first:

Terminal window
seamless clients create "Example CLI" --public --first-party \
--redirect-uri http://127.0.0.1/callback \
--grants authorization_code,refresh_token
import { createServer } from 'node:http'
import { once } from 'node:events'
import open from 'open'
import * as oidc from 'openid-client'
const config = await oidc.discovery(
new URL('https://login.example.com'),
'CLI_CLIENT_ID',
undefined,
oidc.None(),
)
// Port 0: the OS picks a free port on the loopback interface.
const server = createServer()
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const { port } = server.address() as { port: number }
const redirectUri = `http://127.0.0.1:${port}/callback`
const verifier = oidc.randomPKCECodeVerifier()
const state = oidc.randomState()
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: 'openid offline_access',
prompt: 'consent',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state,
})
await open(url.href)
const [request, response] = await once(server, 'request')
const callbackUrl = new URL(request.url, redirectUri)
response.end('Signed in. You can close this tab.')
server.close()
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: verifier,
expectedState: state,
})
// Store tokens.refresh_token in the OS credential store.

offline_access with prompt=consent gets a refresh token, so the CLI does not send the user back to the browser every time the access token expires. See Refresh tokens.


  • The refresh token goes in the OS credential store and nowhere else. If the store is locked or absent, sign-in should fail and say so. Do not fall back to a file.
  • The access token stays in memory. It is short-lived and cheap to refresh.
  • The ID token stays in memory too, for id_token_hint on sign-out. A refresh returns a new one.
  • One refresher at a time. An app with several processes (a tray helper, a CLI, the main window) refreshes through one of them, or behind a lock. In Electron, app.requestSingleInstanceLock() and one in-flight refresh in the main process cover the app itself.
  • Save before use. Store a rotated refresh token before using the new access token, so a crash cannot leave the app holding a spent one.
  • The renderer never sees a token. In Electron or Tauri, keep tokens in the native process and give the page the results of calls, so script running in the page has nothing to take.

A library that reaches the store directly is the simplest route. The example uses @napi-rs/keyring, with { linux: { store: 'secret-service' } }: by default it falls back on Linux to the kernel keyring, which is memory only, so the user would be signed out at every reboot. Two things that look like the same choice are not, as checked on 2026-11-04:

  • keytar, the older Node library, is archived on GitHub.
  • Electron’s safeStorage encrypts data for you to store yourself, and it is not a credential store. On Linux with no secret store, it falls back to a hardcoded password, and safeStorage.getSelectedStorageBackend() returns basic_text. If you use it anyway, check that and refuse.

  • Refresh. Refresh tokens rotate on every use. Save the new one each time, before using the new access token. A refresh token used twice revokes the grant and ends the user’s Seamless session, so two processes of the same app must not refresh with one token at the same time. acr, amr and auth_time on the new access token are the session’s, so a step-up survives a refresh.
  • Tokens for your API. Ask for a token for a named API with resource=<identifier> on the authorization request, using a resource the application is linked to. The access token is an RFC 9068 JWT whose aud is that API. See Access tokens for your API.
  • Signing out of the app. Revoke the refresh token at /token/revocation (a public client sends its client_id), then delete the stored tokens. That ends this app’s grant only. The user stays signed in to Seamless in the browser and in their other apps. An access token already issued keeps working at a backend that verifies tokens locally until it expires, so keep access token lifetimes short (accessTokenTtlSeconds).
  • Signing out of Seamless. To end the user’s Seamless session too, revoke and delete the tokens, then open the end session URL in the system browser, where the Seamless session cookie lives. The hosted page asks the user to confirm. To bring the user back to the app, register a post_logout_redirect_uri and pass it with a state. A loopback URI registered for a native application matches on any port, as a redirect URI does, so the listener can use a port the OS picks. Every other form is matched exactly.
const logoutUrl = oidc.buildEndSessionUrl(config, {
id_token_hint: tokens.id_token!,
post_logout_redirect_uri: 'com.example.desk:/signed-out',
state: oidc.randomState(),
})
await open(logoutUrl.href)

CLIs Without A Browser: Device Authorization Grant

Section titled “CLIs Without A Browser: Device Authorization Grant”

A CLI on a server, over SSH or in a container cannot open a browser for the loopback flow. The device authorization grant (RFC 8628) covers it: the CLI prints a code, and the user approves it in a browser on any other device. It is available to first-party applications with the device flow on:

Terminal window
seamless clients update <id> --device-flow

A CLI can then offer both flows, the loopback flow when a browser is at hand and the device flow when it is not:

const device = await oidc.initiateDeviceAuthorization(config, {
scope: 'openid offline_access',
})
console.error(`Open ${device.verification_uri} and enter ${device.user_code}`)
const tokens = await oidc.pollDeviceAuthorizationGrant(config, device)
// Store tokens.refresh_token in the OS credential store.
  • Codes are 8 characters (BCDF-GHJK), live 10 minutes, and work once.
  • Polling waits interval (5 seconds). A poll that comes too soon gets slow_down; openid-client backs off for you.
  • Approval always signs in again, even in a browser already signed in to Seamless, after a page that names the app and warns the user to continue only for a device they started.
  • No acr. Device tokens never carry acr, after a refresh too, and acr_values or max_age on a device request is refused. Do not use the device flow where an API needs phr or phrh.
  • An expired code currently answers invalid_grant, not RFC 8628’s expired_token (seamless-auth-api#542). Treat both the same: stop polling and start again.

The full flow, poll answers and the verification page are in Device authorization grant.


A passkey is bound to a relying party ID, the instance’s rpid (for example example.com), and a browser only offers it on a page whose origin is that domain or a subdomain of it. The hosted sign-in page runs on the provider’s issuer (for example https://login.example.com), which the API requires to sit under rpid, so every passkey the user already has works there.

An app’s own webview cannot do the same. Its origin is tauri://localhost, app://, file:// or similar, which can never match rpid, so the WebAuthn ceremony fails, and an embedded webview cannot reach the passkeys synced to the user’s browser or phone in any case. Sending the user to the system browser keeps passkeys, saved sessions and any security keys working, and keeps the app out of the credential path.

The same page handles step-up. When a backend asks for a recent passkey, the app opens the system browser again, and the user confirms on the hosted page with the session they already have.


Most desktop apps call their own API. The app holds the user’s tokens and nothing else, and the backend trusts a call by verifying its token locally. The auth API stays private, so the app never reaches it.

desktop app (public client) ──system browser──► login edge ──► auth API (private)
│ access token, aud = backend resource ▲ ▲
▼ │ JWKS │ notices, client credentials
backend API (resource server) ── verify-only adapter ─┘ │ through a client link
└──────────────────────────────────────────────────────────────┘

The design is ADR 0002. Nothing in it is specific to one product.

The example was run against the versions in the last column. Anything earlier is unverified, even where a feature first shipped sooner.

Piece Needs Run against
Public client, loopback redirect, PKCE, API tokens, refresh, revocation The provider, from v0.21 Edge image seamless-auth-oidc-provider:v0.3.0
acr, amr and auth_time on an access token for a resource @seamless-auth/oidc-provider 0.3.0 Edge image v0.3.0
Verify-only guards (createResourceServer) @seamless-auth/express 0.23.0, with the same guards in the Fastify, Next.js, Go, Python and Rust adapters @seamless-auth/express 0.24.0
Client links and clientId on account notices Seamless Auth API v0.23.0, which is not published. API v0.22.0 has no link routes. The API built from source at bcab4d1
Record Registry entry Holds
Desktop app Application, clientType: public, tokenEndpointAuthMethod: none, a loopback or private-use URI Nothing secret
Backend client Application, clientType: confidential, private_key_jwt, client_credentials, notices turned on A private key, on the backend only
Backend API Resource, an absolute URI such as https://api.example.com, with its scopes Nothing; it names the audience

Allow both applications the resource with PUT /admin/applications/:id/resources. Then link the backend client to the desktop app (API v0.23.0) with PUT /admin/applications/<backend id>/links/<desktop id>. A link says the backend acts for that app. The acting client must be confidential and the linked one public, and both must be first-party or both third-party. Every write needs a fresh step-up and is audited.

Use the verify-only guards of your adapter, as in Verify Tokens In Your API. They check the signature, iss, aud, typ and exp locally, and never call the auth API. One check is yours: which clients this API serves. The adapter accepts any valid token for the resource, so without this, another client allowed the same resource could call it. In the example, the backend’s own client credentials token verifies, and this check is what answers it with a 403.

import { createResourceServer } from '@seamless-auth/express'
const api = createResourceServer({
issuer: 'https://login.example.com',
audience: 'https://api.example.com',
})
const clients = new Set(['DESKTOP_CLIENT_ID'])
function requireClient(req, res, next) {
if (clients.has(req.user.clientId)) return next()
res.status(403).json({ error: 'client_not_allowed' })
}
app.get('/notes', api.requireAuth(), requireClient, api.requireScope('notes:read'), listNotes)

The backend decides which routes are sensitive. requireStepUp answers with the RFC 9470 challenge until the token shows what the route asks for:

app.post(
'/notes/export',
api.requireAuth(),
requireClient,
api.requireScope('notes:read'),
api.requireStepUp({ acrValues: ['phr'], maxAge: 300 }),
exportNotes,
)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="A different authentication level is required", acr_values="phr", max_age="300"

The app reads the challenge, accepting quoted or bare values, and then:

  1. Opens the system browser on a new authorization request with the same resource and scopes, plus the acr_values and max_age from the challenge.
  2. The user confirms on the hosted page, which offers their passkey on the session they already have.
  3. The app exchanges the code, stores the new refresh token, and retries the call once.
  4. The app revokes the refresh token it replaced. A step-up starts a new grant, so the old one would otherwise stay usable until it expires.

acr says how strong and max_age says how recent, so a route that needs a passkey within five minutes asks for both. The challenge comes back once auth_time is older than max_age. Do not use the device flow for such a route, since device tokens never carry acr.

Users sign in to the desktop app, so the backend’s own client holds no grants and could reach nobody. A link fixes that. The backend gets a client credentials token with its own key and names the desktop app in clientId. The template names the app the user knows, not the backend.

const { access_token } = await oidc.clientCredentialsGrant(backendConfig, {
resource: 'https://login.example.com/seamless',
scope: 'notices:send',
})
await fetch('https://auth-api.internal.example.com/applications/account-notices', {
method: 'POST',
headers: { authorization: `Bearer ${access_token}`, 'content-type': 'application/json' },
body: JSON.stringify({
userId: req.user.id,
clientId: 'DESKTOP_CLIENT_ID',
subject: 'Your notes export is ready',
text: 'The export you asked for is ready in the app.',
}),
})

A user token cannot send a notice, and neither can a public client, which cannot use client_credentials. The backend’s private key never ships in the app.


examples/native-desktop is an Electron app and an Express backend that do everything on this page against a local instance: system browser sign-in, a loopback redirect on a port the OS picks, PKCE, the OS keychain, refresh, sign-out with revocation, a token for a named API, a route that answers the RFC 9470 challenge until the user steps up, and a notice through a client link. Its README has the steps, and it runs on the local recipe.

CI runs it end to end on a schedule, with a virtual authenticator in Chromium standing in for the user’s passkey and default browser. That covers the flow and the keychain. It does not cover a real browser being opened by the OS, a physical authenticator, or Windows. The README lists exactly what it does and does not check.


examples/native-cli is a small standalone CLI that does the client side of this page against a local instance: login through the system browser with a loopback redirect and PKCE S256, login --device with the device grant, refresh, and logout with revocation. It is a public client with no secret, and its tokens are in the OS keychain with no file fallback. It uses nothing new in API v0.23.0, but the local recipe it runs on builds that unpublished API from source, for its seed’s client links, so it has not been run against a published API.

A CLI differs from a desktop app in three ways, and each one is easy to get wrong:

  • Every command is a new process. Memory does not survive between commands, so the example keeps the access token in the keychain too, with its expiry. Without that, every command would spend a refresh.
  • Commands run at the same time. A refresh token used twice revokes the grant and ends the user’s Seamless session, and two commands in a pipeline or in two terminals are enough. So a refresh takes a lock that spans processes and reads the keychain again once it holds the lock, because the command it waited for has already rotated the token. With the lock removed, the example’s check of six commands at once fails with the grant revoked.
  • stdout belongs to the result. Prompts, URLs and errors go to stderr, so curl -H "Authorization: Bearer $(notes token)" works.

login also prints the URL it opens, and honours the BROWSER environment variable. On a machine with no browser, the printed hint leads to --device.

CI runs it end to end on a schedule, with the real CLI as a child process, the real keychain, and a virtual authenticator in Chromium standing in for the user’s passkey and default browser. That does not cover a real browser being opened by the OS, a physical authenticator, Windows, a locked keychain, or a device code that expires. The README lists exactly what it does and does not check.

Signing In To Seamless With The Seamless CLI

Section titled “Signing In To Seamless With The Seamless CLI”

seamless profile login follows the same rules for one more purpose: an operator signing in to an instance they administer. It differs from the example above in what it keeps. It uses the provider sign-in once, then exchanges the provider’s access token at POST /sessions/exchange for a first-party session, stores that session in the OS keychain, and keeps no token from the provider.

Terminal window
seamless profile login prod # system browser, loopback redirect, PKCE S256
seamless profile login prod --device # the device grant, for a machine with no browser
seamless profile login prod \
--oidc-issuer https://auth.example.com --client-id <client id>
  • The exchange token. The access token is a single-use session:exchange token that lasts 60 seconds. The CLI never retries an exchange: presenting the token a second time revokes the session the first attempt created (ADR 0003).
  • Finding the provider. Flags win over the saved profile, which wins over oidcProvider { issuer, cliClientId } on GET /system-config/public. So an instance that stops advertising cannot downgrade a profile. With no provider anywhere, the CLI uses an email code.
  • Trusting the advertisement. The CLI reads it only over https or on loopback. An advertised issuer on another origin needs the user’s confirmation, and without a terminal the CLI stops and asks for the flags. Instances cannot set oidc_cli_client_id through seamless config yet (seamless-cli#351), so plan on passing --oidc-issuer and --client-id once. The sign-in saves them to the profile.
  • Device sessions step up. A session from --device must step up with the authenticator app before admin and organization commands, reads included, because whoever approved the code may not be whoever holds this terminal.
  • The client. An admin registers a first-party public client with a loopback redirect, with session exchange on, and the device grant on for --device:
Terminal window
seamless clients create "Seamless CLI" --public --first-party --auth-method none \
--redirect-uri http://127.0.0.1/callback --device-flow --session-exchange

The session exchange is for first-party admin access, so your own CLI should not copy this part. Copy the example, and call your API with the provider’s token. See the CLI reference for the flags.


Today, every token on this page is a bearer token: whoever holds it can use it, which is why the storage rules above are the protection. DPoP (RFC 9449) binds a token to a key the app holds, so a stolen token alone would not be enough. When #512 ships, this page should recommend a non-exportable key in the platform keystore where one exists. On the backend, the verify-only guards already refuse a sender-constrained token (cnf) unless you pass a confirm hook, which is where a proof check would plug in.


The provider is locked to OAuth 2.1 and RFC 9700. These are refused for every client, native or not (a confidential client can be opted out of PKCE, a public one never):

Refused Answer
Implicit and hybrid flows (response_type=token, id_token) unsupported_response_type or invalid_request
PKCE plain, or no PKCE on a public client invalid_request
Resource owner password grant (grant_type=password) unsupported_grant_type
A redirect URI that does not match a registered one An error page; the code is never sent there
localhost or a non-loopback http redirect Refused at registration and at sign-in
A client secret on a public client Refused at registration
client_credentials for a public client Refused at registration
An authorization code redeemed twice invalid_grant, and the tokens from the first use are revoked
Dynamic client registration Not offered: applications come only from the registry