Guide · Native apps
Desktop and command-line apps
An Electron app, a CLI, or any native app signs in through the system browser and comes back to a loopback port. No client secret, no embedded login window.
How it works
A native app is different from a web app in one way that matters: whatever ships inside the binary is public. Anybody who installs it can read it. So there is no client secret. Three things replace it.
The system browser signs the person in
The app opens the Authfu authorize URL in the person’s default browser, never in a window the app controls. An embedded window can read what is typed into it, so a real Authfu prompt and an app pretending to be one would look identical. The system browser already holds the session, shows a real address bar, and has the password manager.The browser comes back to a loopback port
Before opening the browser, the app listens on127.0.0.1on a port the operating system picks. The redirect URI ishttp://127.0.0.1:<port>/callback. Authfu treats loopback redirects as port-agnostic, so you register the address once without a port. Nothing off the machine can reach the listener.PKCE proves the exchange is the same app
The app generates a random verifier, sends its SHA-256 hash with the authorize request, and sends the verifier itself with the token request. A code stolen in transit is useless without the verifier, which never leaves the process. Authfu requires the S256 method and rejectsplain.
Register the app
In the console, create an app and add one redirect URI:
http://127.0.0.1/callbackThat single entry matches every port. Use 127.0.0.1 rather than localhost, which RFC 8252 advises against because it can resolve off the machine. Copy the client id into the app. There is no secret to copy; the client id is safe to ship in the binary and commit to source control.
The sign-in code
Copy authfu-native-signin.ts into your project. It is about 180 lines, uses only node:crypto, node:http, and global fetch, and exports four functions: signInWithAuthfu, refreshAuthfuTokens, authfuUserinfo, and openInDefaultBrowser.
import { signInWithAuthfu, authfuUserinfo } from "./authfu-native-signin";
const tokens = await signInWithAuthfu({
clientId: process.env.AUTHFU_CLIENT_ID!, // public client: no secret anywhere
appName: "Acme Desktop",
openBrowser: (url) => shell.openExternal(url), // Electron; CLIs use openInDefaultBrowser
});
const me = await authfuUserinfo(tokens.accessToken);
// { sub, email, email_verified, name, picture, roles: [...] }The function discovers the endpoints from https://authfu.app/.well-known/openid-configuration, opens the browser, waits up to five minutes for the redirect, checks state in constant time, exchanges the code with the PKCE verifier, and closes the listener. If the person refuses or is not allowed, it throws with the provider’s error description.
Access tokens last an hour and come with a refresh token that lasts thirty days. Refresh before expiry:
import { refreshAuthfuTokens } from "./authfu-native-signin";
if (Date.now() > stored.expiresAt - 60_000 && stored.refreshToken) {
stored = await refreshAuthfuTokens({
clientId: process.env.AUTHFU_CLIENT_ID!,
refreshToken: stored.refreshToken,
});
}Electron
Do the whole flow in the main process. The renderer never sees a token: it asks main to sign in, and it asks main to call your API. Store the session with safeStorage, which encrypts with the operating system keychain.
// main.ts — the only process that ever touches tokens
import { app, ipcMain, safeStorage, shell } from "electron";
import { signInWithAuthfu } from "./authfu-native-signin";
let session: { accessToken: string; refreshToken: string | null; expiresAt: number } | null = null;
ipcMain.handle("auth:sign-in", async () => {
session = await signInWithAuthfu({
clientId: process.env.AUTHFU_CLIENT_ID!,
appName: app.getName(),
openBrowser: (url) => shell.openExternal(url),
});
// Encrypt with the OS keychain before writing to disk. Never hand tokens to the renderer.
await writeFile(sessionPath, safeStorage.encryptString(JSON.stringify(session)));
return { signedIn: true };
});
// The renderer asks main to call your API; main attaches the bearer.
ipcMain.handle("api:get", async (_event, path: string) => {
if (!/^\/api\/[a-z0-9/-]+$/.test(path)) throw new Error("Unknown path.");
const response = await fetch(`https://api.acme.example${path}`, {
headers: { Authorization: `Bearer ${session!.accessToken}` },
});
return response.json();
});Two settings matter. contextIsolation and sandbox keep the renderer away from Node and from anything main holds. And the IPC handler that calls your API accepts a fixed list of paths, so a compromised renderer cannot turn main into an open proxy with a bearer token attached.
Use shell.openExternal to open the browser. Never load the authorize URL in a BrowserWindow.
Command-line tools
A CLI is the same flow with a different way of opening the browser. openInDefaultBrowser runs open, xdg-open, or start for the platform and prints the URL first, so a person on an SSH session or a machine with no browser can paste it somewhere else. The browser on that other machine will not reach your loopback listener, so for headless machines use the device flow instead, which is what authfu login itself does.
#!/usr/bin/env node
import { signInWithAuthfu, openInDefaultBrowser, authfuUserinfo } from "./authfu-native-signin";
const tokens = await signInWithAuthfu({
clientId: "cm…your-client-id", // fine to hardcode: it is public
appName: "acme",
openBrowser: openInDefaultBrowser, // opens the browser, prints the URL as a fallback
});
const me = await authfuUserinfo(tokens.accessToken);
console.log(`Signed in as ${me.email}`);
// Store tokens with the OS keychain (keytar / @napi-rs/keyring), not a plain file.Store the tokens in the OS keychain rather than a plain file in the home directory. Every refresh token is bound to the person and the app, and revoking the person’s access in the console stops the next refresh.
Verify tokens on your backend
The desktop app calls your own API with the access token as a bearer. Access tokens from the authorization code flow are opaque, so your backend asks Authfu whether the token is good. Two ways, both one request:
// Any language. One call, the same token the app got.
const response = await fetch("https://authfu.app/api/oauth/userinfo", {
headers: { Authorization: request.headers.get("authorization")! },
});
if (!response.ok) return new Response("Sign in again.", { status: 401 });
const user = await response.json(); // { sub, email, email_verified, name, roles }The userinfo endpoint is the simplest: pass the same bearer through and read back who it is, with roles. Introspection (RFC 7662) needs your backend registered as its own confidential client and returns the token’s status, scope, and expiry as well. The ID token, when you ask for it, is a signed RS256 JWT you can verify offline against Authfu’s JWKS, but it identifies the person to the app and is not meant to be sent to your API.
FAQ
Can I use a custom URL scheme instead of loopback? Yes. Authfu accepts private-use scheme redirects such as com.acme.app:/callback. Loopback is the better default on desktop because the operating system does not let another app claim your scheme in a way a person would notice. On mobile, use the platform’s app links.
What if the port is taken? It cannot be. The listener asks for port 0 and the OS assigns a free one. That is why the registered redirect carries no port.
Does two-factor still apply? Yes. The person signs in on the hosted Authfu page in their own browser, so the authenticator step, passkeys, and federated sign-in all behave exactly as they do for a web app.
Can I ship this to people outside my company? That is the app’s access policy, not the flow. A public policy lets any Authfu account sign in. Invite-only or verified-domain policies restrict it. The binary is the same either way.