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.
The Rules
Section titled “The Rules”- 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. - 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
ASWebAuthenticationSessionon macOS and iOS, Custom Tabs on Android). - Use PKCE with
S256. Every client must. The edge refusesplainand requests without PKCE. - Receive the code on a loopback redirect or a private-use scheme. See the next section.
- 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.
Redirect URIs
Section titled “Redirect URIs”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/callbackrequested: http://127.0.0.1:53124/callback acceptedrequested: http://[::1]:61000/callback accepted if http://[::1]/callback is registeredrequested: 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.
Why not localhost
Section titled “Why not localhost”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 CLI Example
Section titled “A CLI Example”A complete loopback sign-in for a Node CLI, with openid-client.
Register the client first:
seamless clients create "Example CLI" --public --first-party \ --redirect-uri http://127.0.0.1/callback \ --grants authorization_code,refresh_tokenimport { 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.
Keep Tokens In The OS Credential Store
Section titled “Keep Tokens In The OS Credential Store”- 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_hinton 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
safeStorageencrypts 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, andsafeStorage.getSelectedStorageBackend()returnsbasic_text. If you use it anyway, check that and refuse.
Refresh, API Tokens And Sign-Out
Section titled “Refresh, API Tokens And Sign-Out”- 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,amrandauth_timeon 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 whoseaudis that API. See Access tokens for your API. - Signing out of the app. Revoke the refresh token at
/token/revocation(a public client sends itsclient_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_uriand pass it with astate. 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:
seamless clients update <id> --device-flowA 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 getsslow_down;openid-clientbacks 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 carryacr, after a refresh too, andacr_valuesormax_ageon a device request is refused. Do not use the device flow where an API needsphrorphrh. - An expired code currently answers
invalid_grant, not RFC 8628’sexpired_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.
Why Passkeys Run On The Hosted Page
Section titled “Why Passkeys Run On The Hosted Page”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.
An App With Its Own Backend
Section titled “An App With Its Own Backend”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 credentialsbackend API (resource server) ── verify-only adapter ─┘ │ through a client link └──────────────────────────────────────────────────────────────┘The design is ADR 0002. Nothing in it is specific to one product.
Versions
Section titled “Versions”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 |
Register Three Records
Section titled “Register Three Records”| 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.
Verify Tokens In The Backend
Section titled “Verify Tokens In The Backend”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)Require A Recent Passkey On One Route
Section titled “Require A Recent Passkey On One Route”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 UnauthorizedWWW-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:
- Opens the system browser on a new authorization request with the same
resourceand scopes, plus theacr_valuesandmax_agefrom the challenge. - The user confirms on the hosted page, which offers their passkey on the session they already have.
- The app exchanges the code, stores the new refresh token, and retries the call once.
- 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.
Send A Notice Through The Client Link
Section titled “Send A Notice Through The Client Link”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.
A Runnable Desktop Example
Section titled “A Runnable Desktop Example”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.
A Runnable CLI Example
Section titled “A Runnable CLI Example”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.
seamless profile login prod # system browser, loopback redirect, PKCE S256seamless profile login prod --device # the device grant, for a machine with no browserseamless profile login prod \ --oidc-issuer https://auth.example.com --client-id <client id>- The exchange token. The access token is a single-use
session:exchangetoken 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 }onGET /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_idthroughseamless configyet (seamless-cli#351), so plan on passing--oidc-issuerand--client-idonce. The sign-in saves them to the profile. - Device sessions step up. A session from
--devicemust 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:
seamless clients create "Seamless CLI" --public --first-party --auth-method none \ --redirect-uri http://127.0.0.1/callback --device-flow --session-exchangeThe 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.
Where DPoP Will Fit
Section titled “Where DPoP Will Fit”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.
What OAuth 2.1 Refuses
Section titled “What OAuth 2.1 Refuses”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 |