Kinde and edge worker services
Integrations
This guide shows you how to integrate Kinde authentication into a desktop app built with Electron. You’ll set up a basic Electron project, configure Kinde application, and implement login, registration, logout, and user profile access.
http://127.0.0.1:53180/callback.http://127.0.0.1:53180/logout-complete.Create a new project with the terminal command:
mkdir electron-kinde-authcd electron-kinde-authCreate a new package.json file and add the following:
touch package.json{ "name": "electron-kinde-auth", "description": "A simple Electron app to authenticate with Kinde using OAuth", "productName": "Electron Kinde Auth", "version": "1.0.0", "main": "main.js", "scripts": { "start": "electron .", "dev": "electron ." }, "dependencies": { "express": "^4.19.2", "jose": "^6.2.3" }, "devDependencies": { "electron": "^31.3.0" }}Run the following command to install the required packages:
npm installCreate a .gitignore file to keep generated files out of version control:
touch .gitignoreecho "node_modules/" >> .gitignoreecho "dist/" >> .gitignoreCreate a kinde.config.js file for your Kinde credentials and add the Domain and Client ID copied from the Kinde Details page:
touch kinde.config.jsmodule.exports = { issuerUrl: "https://<YOUR_KINDE_DOMAIN>.kinde.com", clientId: "<YOUR_CLIENT_ID>", scopes: "openid profile email offline", // audience: "", // Uncomment if you have a custom API audience}The offline scope requests a refresh token so the app can silently renew the access token without prompting the user to log in again. Note that Kinde uses offline — not offline_access as the OIDC spec defines — so using the standard name will not work.
A bundled config file is the idiomatic approach for desktop apps. Unlike .env files — which are a server-side convention — this file travels with the app. Since this guide uses PKCE (no client secret), these values are not sensitive.
If you plan to open-source the project, add kinde.config.js to .gitignore to avoid committing your specific Kinde domain and client ID.
Create a new helpers.js file and add the following:
touch helpers.jsconst crypto = require("crypto")
function base64urlencode(buf) { return buf .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, "")}
function generateVerifier() { return base64urlencode(crypto.randomBytes(32))}function challengeFromVerifier(v) { return base64urlencode(crypto.createHash("sha256").update(v).digest())}
function randomState(len = 12) { return crypto .randomBytes(Math.ceil((len * 3) / 4)) .toString("base64url") .slice(0, len)}
// For UI display only — do not use for security decisionsfunction decodeIdToken(idToken) { try { const [, payload] = idToken.split(".") const pad = (s) => s + "=".repeat((4 - (s.length % 4)) % 4) const json = Buffer.from( pad(payload).replace(/-/g, "+").replace(/_/g, "/"), "base64" ).toString("utf8") return JSON.parse(json) } catch { return null }}
// jose v5+ is ESM-only and cannot be require()'d in a CommonJS Electron app.// Dynamic import() works from CJS — joseCache stores the Promise so the module loads once.function createTokenVerifier(issuer, audience) { let joseCache = null let JWKS = null
return async function verifyToken(token) { if (!joseCache) joseCache = import("jose") const { createRemoteJWKSet, jwtVerify } = await joseCache if (!JWKS) JWKS = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)) const options = { issuer, algorithms: ["RS256"] } if (audience) options.audience = audience const { payload } = await jwtVerify(token, JWKS, options) return payload }}
module.exports = { generateVerifier, challengeFromVerifier, randomState, decodeIdToken, createTokenVerifier,}Create a new main.js file and add the following:
touch main.jsconst { app, BrowserWindow, ipcMain, shell, safeStorage } = require("electron")const path = require("path")const fs = require("fs")const express = require("express")
const { generateVerifier, challengeFromVerifier, randomState, decodeIdToken, createTokenVerifier,} = require("./helpers")
const config = require("./kinde.config")
// ---------- Config ----------const CALLBACK_HOST = "127.0.0.1"const CALLBACK_PORT = 53180const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/callback`const LOGOUT_REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/logout-complete`
const ISSUER = config.issuerUrlconst CLIENT_ID = config.clientIdconst AUDIENCE = config.audience || ""const SCOPES = (config.scopes || "openid profile email offline").trim()
if (!ISSUER || !CLIENT_ID || ISSUER.includes("<") || CLIENT_ID.includes("<")) { console.error("Please configure kinde.config.js with your Kinde credentials")}
// ---------- Token verifier (JWKS-backed, initialized once at startup) ----------const verifyToken = createTokenVerifier(ISSUER, AUDIENCE || undefined)
// ---------- Token storage (safeStorage + OS-encrypted file) ----------function getStorePath() { return path.join(app.getPath("userData"), "kinde-tokens.enc")}
const tokenStore = { async load() { try { const p = getStorePath() if (!fs.existsSync(p) || !safeStorage.isEncryptionAvailable()) return null const encrypted = fs.readFileSync(p) const json = safeStorage.decryptString(encrypted) const t = JSON.parse(json) if (!t || typeof t.access_token !== "string") return null return t } catch { return null } }, async save(tokens) { if (!safeStorage.isEncryptionAvailable()) { throw new Error("OS encryption is not available") } const encrypted = safeStorage.encryptString(JSON.stringify({ ...tokens })) fs.writeFileSync(getStorePath(), encrypted) }, async clear() { try { const p = getStorePath() if (fs.existsSync(p)) fs.unlinkSync(p) } catch {} }, async exists() { return fs.existsSync(getStorePath()) },}
// ---------- Small helpers ----------function stampIssued(tokens) { const t = { ...tokens } t.issued_at = Date.now() if (typeof t.expires_in === "number") t.expires_at = t.issued_at + t.expires_in * 1000 return t}
async function postForm(url, data) { const body = new URLSearchParams(data) const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }) const text = await res.text() if (!res.ok) throw new Error(`${res.status} ${text}`) return JSON.parse(text)}
// ---------- OAuth helpers ----------async function exchangeCodeForTokens({ code, codeVerifier, redirectUri }) { const tokenUrl = new URL("/oauth2/token", ISSUER).toString() const json = await postForm(tokenUrl, { grant_type: "authorization_code", code, client_id: CLIENT_ID, redirect_uri: redirectUri, code_verifier: codeVerifier, }) return stampIssued(json)}
async function refreshTokens(refreshToken) { const tokenUrl = new URL("/oauth2/token", ISSUER).toString() const json = await postForm(tokenUrl, { grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, }) // Some providers omit refresh_token on refresh — keep the old one if (!json.refresh_token) json.refresh_token = refreshToken return stampIssued(json)}
async function getValidAccessToken() { const tokens = await tokenStore.load() if (!tokens) return null
const expiresAt = tokens.expires_at ?? (tokens.issued_at || 0) + (tokens.expires_in || 0) * 1000
const aboutToExpire = !expiresAt || Date.now() + 60_000 >= expiresAt // refresh when <60s left if (!aboutToExpire) return tokens.access_token
if (!tokens.refresh_token) return null const refreshed = await refreshTokens(tokens.refresh_token) await verifyToken(refreshed.access_token) await tokenStore.save(refreshed) return refreshed.access_token}
// ---------- Callback server (single fixed port) ----------function listenForCallback(expectedState, timeoutMs = 5 * 60 * 1000) { const appx = express() let server
let resolveLogin, rejectLogin const waitForCode = new Promise((resolve, reject) => { resolveLogin = resolve rejectLogin = reject })
// Reject if the user closes the browser without completing auth const timer = setTimeout(() => { try { server?.close() } catch {} rejectLogin(new Error("Login timed out — close the browser window and try again")) }, timeoutMs)
appx.get("/callback", (req, res) => { clearTimeout(timer) const { code, state, error, error_description } = req.query
if (state !== expectedState) { res .status(400) .send("<h1>Invalid state</h1><p>Please try signing in again.</p>") try { server?.close() } catch {} return rejectLogin(new Error("Invalid OAuth state")) }
if (error) { res .status(400) .send(`<h1>Login error</h1><p>${error}: ${error_description || ""}</p>`) try { server?.close() } catch {} return rejectLogin(new Error(`${error}: ${error_description || ""}`)) }
res.send( "<h1>Login successful</h1><p>You can close this window and return to the app.</p>" ) try { server?.close() } catch {} return resolveLogin({ code: String(code), redirectUri: REDIRECT_URI }) })
server = appx.listen(CALLBACK_PORT, CALLBACK_HOST) server.on("error", (err) => { const msg = err?.code === "EADDRINUSE" ? `Callback port ${CALLBACK_PORT} is already in use. Close the other process or change the port.` : String(err) try { server?.close() } catch {} rejectLogin(new Error(msg)) })
return { waitForCode }}
// ---------- Logout callback server ----------function listenForLogoutCallback(timeoutMs = 2 * 60 * 1000) { const appx = express() let server
appx.get("/logout-complete", (_req, res) => { res.send( "<h1>You're signed out</h1><p>You can close this window and return to the app.</p>" ) try { server?.close() } catch {} })
server = appx.listen(CALLBACK_PORT, CALLBACK_HOST) server.on("error", () => { try { server?.close() } catch {} })
// Auto-close if the browser never redirects (e.g. network error) setTimeout(() => { try { server?.close() } catch {} }, timeoutMs)}
// ---------- Login flow ----------async function startLogin({ register = false, orgCode } = {}) { const codeVerifier = generateVerifier() const codeChallenge = challengeFromVerifier(codeVerifier) const state = randomState()
const { waitForCode } = listenForCallback(state)
const auth = new URL("/oauth2/auth", ISSUER) auth.searchParams.set("client_id", CLIENT_ID) auth.searchParams.set("response_type", "code") auth.searchParams.set("redirect_uri", REDIRECT_URI) auth.searchParams.set("scope", SCOPES) auth.searchParams.set("code_challenge_method", "S256") auth.searchParams.set("code_challenge", codeChallenge) auth.searchParams.set("state", state) if (AUDIENCE) auth.searchParams.set("audience", AUDIENCE) if (register) auth.searchParams.set("prompt", "create") if (orgCode) auth.searchParams.set("organization", orgCode)
await shell.openExternal(auth.toString())
const { code } = await waitForCode const tokens = await exchangeCodeForTokens({ code, codeVerifier, redirectUri: REDIRECT_URI, })
// Verify the access token against Kinde's JWKS before trusting or persisting it await verifyToken(tokens.access_token)
await tokenStore.save(tokens) const claims = decodeIdToken(tokens.id_token) return { tokens, claims }}
async function doLogout() { await tokenStore.clear() try { listenForLogoutCallback() const url = new URL("/logout", ISSUER) url.searchParams.set("client_id", CLIENT_ID) url.searchParams.set("post_logout_redirect_uri", LOGOUT_REDIRECT_URI) await shell.openExternal(url.toString()) } catch {}}
// ---------- Electron window ----------let winfunction createWindow() { win = new BrowserWindow({ width: 1000, height: 700, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) // Open target="_blank" / window.open links in the system browser win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: "deny" } }) win.loadFile(path.join(__dirname, "renderer", "index.html"))}
app.whenReady().then(() => { createWindow() app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() })})
app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit()})
// ---------- IPC ----------ipcMain.handle("auth:login", async (_e, { orgCode } = {}) => { try { const { claims } = await startLogin({ orgCode }) return { ok: true, claims } } catch (e) { return { ok: false, error: String(e) } }})
ipcMain.handle("auth:register", async (_e, { orgCode } = {}) => { try { const { claims } = await startLogin({ register: true, orgCode }) return { ok: true, claims } } catch (e) { return { ok: false, error: String(e) } }})
ipcMain.handle("auth:getAccessToken", async () => { try { const token = await getValidAccessToken() return { ok: true, access_token: token } } catch (e) { return { ok: false, error: String(e) } }})
ipcMain.handle("auth:logout", async () => { try { await doLogout() return { ok: true } } catch (e) { return { ok: false, error: String(e) } }})
ipcMain.handle("auth:getSession", async () => { try { const tokens = await tokenStore.load() if (!tokens) return { ok: true, signedIn: false } try { const token = await getValidAccessToken() if (!token) { // Token expired with no valid refresh token — clear and sign out await tokenStore.clear() return { ok: true, signedIn: false } } } catch { // Network may be offline — preserve the session rather than signing out } const claims = decodeIdToken(tokens.id_token) return { ok: true, signedIn: true, claims } } catch (e) { return { ok: false, error: String(e) } }})Create a new preload.js file and add the following:
touch preload.jsconst { contextBridge, ipcRenderer } = require("electron")
contextBridge.exposeInMainWorld("kindeAuth", { login: (orgCode) => ipcRenderer.invoke("auth:login", { orgCode }), register: (orgCode) => ipcRenderer.invoke("auth:register", { orgCode }), getAccessToken: () => ipcRenderer.invoke("auth:getAccessToken"), logout: () => ipcRenderer.invoke("auth:logout"), getSession: () => ipcRenderer.invoke("auth:getSession"),})Run the following to set up your project structure:
mkdir renderertouch renderer/index.html renderer/style.css renderer/renderer.jsAdd the following code to index.html file to create the UI:
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8" /> <title>Kinde Auth App</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <!-- CSP allows local file + external HTTPS connects for token/API calls --> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https: http:;" /> <link rel="stylesheet" href="./style.css" /> <style> .hidden { display: none !important; } </style> </head>
<body> <!-- Header / Nav --> <header> <nav class="nav container"> <h1 class="text-display-3">KindeAuth</h1>
<!-- Signed-out nav --> <div id="nav-guest"> <button class="btn btn-ghost sign-in-btn" id="signInBtn"> Sign in </button> <button class="btn btn-dark" id="signUpBtn">Sign up</button> </div>
<!-- Signed-in nav --> <div id="nav-authed" class="profile-blob hidden"> <img id="avatar" class="avatar" src="" alt="User avatar" /> <div> <p class="text-heading-2" id="fullName">—</p> <a class="text-subtle" href="#" id="signOutLink">Sign out</a> </div> </div> </nav> </header>
<!-- Main --> <main> <div class="container"> <!-- Guest hero --> <section id="guest-hero" class="card hero"> <p class="text-display-2 hero-title"> Let’s start authenticating </p> <p class="text-display-2 hero-title">with KindeAuth</p> <p class="text-body-1 hero-tagline">Configure your app</p> <a class="btn btn-light btn-big" href="https://docs.kinde.com" target="_blank" rel="noreferrer" > Go to docs </a> </section>
<!-- Authed hero --> <section id="authed-hero" class="card start-hero hidden"> <p class="text-body-2 start-hero-intro">Woohoo!</p> <p class="text-display-2">Your authentication is all sorted.</p> <p class="text-display-2">Build the important stuff.</p> </section>
<!-- Next steps --> <section id="next-steps" class="next-steps-section hidden"> <h2 class="text-heading-1">Next steps for you</h2> <ul class="next-steps-list"> <li class="text-body-3"> Call your API using a fresh access token </li> <li class="text-body-3"> Use ID token claims to personalize the UI </li> <li class="text-body-3">Wire billing/entitlements as needed</li> </ul> </section>
<!-- Token and claims panel (signed-in only) --> <section id="debug-panel" class="debug-panel hidden"> <div class="debug-row"> <h3 class="text-heading-1">ID token claims</h3> <pre id="claims" class="code-block"></pre> </div> <div class="debug-row"> <h3 class="text-heading-1">Access token</h3> <button class="btn btn-dark" id="getTokenBtn">Get access token</button> <pre id="token" class="code-block"></pre> </div> </section> </div> </main>
<!-- Footer --> <footer class="footer"> <div class="container"> <strong class="text-heading-2">KindeAuth</strong> <p class="footer-tagline text-body-3"> Visit our <a class="link" href="https://kinde.com/docs" target="_blank" rel="noreferrer">help center</a> </p> <small class="text-subtle" >© 2026 KindeAuth, Inc. All rights reserved</small > </div> </footer>
<script src="./renderer.js"></script> </body></html>In production, register win.webContents.setWindowOpenHandler so external links (footer help center, docs CTA) open with shell.openExternal instead of navigating your BrowserWindow.
Add the following code to style.css file to style the UI.
:root { --g-color-black: #000; --g-color-white: #fff;
--g-color-grey-50: #f6f6f6; --g-color-grey-600: #636363; --g-color-grey-700: #4d4d4d; --g-color-grey-900: #0f0f0f;
--g-box-shadow: 0px 6px 12px rgba(18, 20, 23, 0.06), 0px 15px 24px rgba(18, 20, 23, 0.07), 0px -4px 12px rgba(18, 20, 23, 0.05);
--g-font-family: Helvetica, sans-serif;
--g-font-size-x-small: 0.75rem; /* 12px */ --g-font-size-small: 0.875rem; /* 14px */ --g-font-size-base: 1rem; /* 16px */ --g-font-size-large: 1.25rem; /* 20px */ --g-font-size-x-large: 1.5rem; /* 24px */ --g-font-size-2x-large: 2rem; /* 32px */ --g-font-size-3x-large: 2.5rem; /* 40px */ --g-font-size-4x-large: 4rem; /* 64px */
--g-font-weight-base: 400; --g-font-weight-semi-bold: 500; --g-font-weight-bold: 600; --g-font-weight-black: 700;
--g-border-radius-small: 0.5rem; --g-border-radius-base: 1rem; --g-border-radius-large: 1.5rem;
--g-spacing-small: 0.5rem; /* 8px */ --g-spacing-base: 1rem; /* 16px */ --g-spacing-large: 1.5rem; /* 24px */ --g-spacing-x-large: 2rem; /* 32px */ --g-spacing-2x-large: 2.5rem; /* 40px */ --g-spacing-3x-large: 3rem; /* 48px */ --g-spacing-6x-large: 6rem; /* 96px */}
* { padding: 0; margin: 0; box-sizing: border-box;}
html,body { font-family: var(--g-font-family);}
a { color: inherit; text-decoration: none;}
.text-subtle { color: var(--g-color-grey-600); font-size: var(--g-font-size-x-small); font-weight: var(--g-font-weight-base);}
.text-body-1 { font-size: var(--g-font-size-2x-large); font-weight: var(--g-font-weight-base);}
.text-body-2 { font-size: var(--g-font-size-x-large); font-weight: var(--g-font-weight-base);}
.text-body-3 { color: var(--g-color-grey-900); font-size: var(--g-font-size-small); font-weight: var(--g-font-weight-base);}
.text-display-1 { font-size: var(--g-font-size-4x-large); font-weight: var(--g-font-weight-black); line-height: 1.2;}
.text-display-2 { font-size: var(--g-font-size-3x-large); font-weight: var(--g-font-weight-black); line-height: 1.4;}
.text-display-3 { font-size: var(--g-font-size-x-large); font-weight: var(--g-font-weight-black);}
.text-heading-1 { font-size: var(--g-font-size-large); font-weight: var(--g-font-weight-semi-bold);}
.text-heading-2 { font-size: var(--g-font-size-base); font-weight: var(--g-font-weight-semi-bold);}
.container { padding: 0 var(--g-spacing-6x-large); margin: auto;}
.nav { align-items: center; display: flex; justify-content: space-between; padding-bottom: var(--g-spacing-x-large); padding-top: var(--g-spacing-x-large); width: 100%;}
.sign-in-btn { margin-right: var(--g-spacing-small);}
.btn { border-radius: var(--g-border-radius-small); display: inline-block; font-weight: var(--g-font-weight-bold); padding: var(--g-spacing-base); cursor: pointer; border: none;}
.btn-ghost { color: var(--g-color-grey-700);}
.btn-dark { background-color: var(--g-color-black); color: var(--g-color-white);}
.btn-light { background: var(--g-color-white); color: var(--g-color-black); font-weight: 600;}
.btn-big { font-size: var(--g-font-size-large); padding: var(--g-font-size-large) var(--g-font-size-x-large);}
.hero { align-items: center; display: flex; flex-direction: column; height: 30rem; justify-content: center; text-align: center;}
.hero-title { margin-bottom: var(--g-spacing-x-large);}
.hero-tagline { margin-bottom: var(--g-spacing-x-large);}
.card { background: var(--g-color-black); border-radius: var(--g-border-radius-large); box-shadow: var(--g-box-shadow); color: var(--g-color-white);}
.link { text-decoration: underline; text-underline-offset: 0.2rem;}
.link:hover,.link:focus { background: #f1f2f4;}
.footer { padding-bottom: var(--g-spacing-x-large); padding-top: var(--g-spacing-x-large);}
.footer-tagline { margin-bottom: var(--g-font-size-x-small); margin-top: var(--g-font-size-x-small);}
.start-hero { padding: var(--g-spacing-2x-large); text-align: center;}
.start-hero-intro { margin-bottom: var(--g-spacing-base);}
.avatar { align-items: center; background-color: var(--g-color-grey-50); border-radius: var(--g-border-radius-large); display: flex; height: var(--g-spacing-3x-large); justify-content: center; text-align: center; width: var(--g-spacing-3x-large);}
.profile-blob { align-items: center; display: grid; gap: var(--g-spacing-base); grid-template-columns: auto 1fr;}
.next-steps-section { margin-top: var(--g-spacing-2x-large);}
.next-steps-list { display: flex; flex-direction: column; gap: var(--g-spacing-small); list-style: disc; margin-top: var(--g-spacing-base); padding-left: var(--g-spacing-x-large);}
.debug-panel { display: flex; flex-direction: column; gap: var(--g-spacing-x-large); margin-top: var(--g-spacing-2x-large);}
.debug-row { display: flex; flex-direction: column; gap: var(--g-spacing-small);}
.code-block { background: var(--g-color-grey-50); border-radius: var(--g-border-radius-small); font-family: monospace; font-size: var(--g-font-size-x-small); overflow-x: auto; padding: var(--g-spacing-base); white-space: pre-wrap; word-break: break-all;}Add the following code to renderer.js file to make everything work.
const els = { navGuest: document.getElementById("nav-guest"), navAuthed: document.getElementById("nav-authed"), guestHero: document.getElementById("guest-hero"), authedHero: document.getElementById("authed-hero"), nextSteps: document.getElementById("next-steps"), debugPanel: document.getElementById("debug-panel"), claimsPre: document.getElementById("claims"), tokenPre: document.getElementById("token"), signInBtn: document.getElementById("signInBtn"), signUpBtn: document.getElementById("signUpBtn"), signOutLink: document.getElementById("signOutLink"), avatar: document.getElementById("avatar"), fullName: document.getElementById("fullName"), getTokenBtn: document.getElementById("getTokenBtn"),}
function safeSetText(el, text) { if (el) el.textContent = text}function safeSetSrc(el, src, alt = "") { if (!el) return if (src) { el.src = src el.alt = alt || "" } else { el.removeAttribute("src") }}
function setAuthedUI(on, claims) { if (on) { els.navGuest?.classList.add("hidden") els.navAuthed?.classList.remove("hidden") els.guestHero?.classList.add("hidden") els.authedHero?.classList.remove("hidden") els.nextSteps?.classList.remove("hidden") els.debugPanel?.classList.remove("hidden")
const name = [claims?.given_name, claims?.family_name].filter(Boolean).join(" ") || claims?.name || "Signed in" safeSetText(els.fullName, name) safeSetSrc(els.avatar, claims?.picture, name) } else { els.navGuest?.classList.remove("hidden") els.navAuthed?.classList.add("hidden") els.guestHero?.classList.remove("hidden") els.authedHero?.classList.add("hidden") els.nextSteps?.classList.add("hidden") els.debugPanel?.classList.add("hidden") safeSetText(els.claimsPre, "{}") safeSetText(els.tokenPre, '(click "Get access token")') }}
// --- Startup: restore session if present ---async function bootstrap() { try { const res = await window.kindeAuth.getSession() if (res?.ok && res.signedIn) { if (els.claimsPre) els.claimsPre.textContent = JSON.stringify(res.claims || {}, null, 2) setAuthedUI(true, res.claims) } else { setAuthedUI(false) } } catch { setAuthedUI(false) }}
function handleAuthResult(res) { if (!res.ok) { safeSetText(els.claimsPre, "Login failed: " + res.error) setAuthedUI(false) } else { if (els.claimsPre) els.claimsPre.textContent = JSON.stringify(res.claims, null, 2) setAuthedUI(true, res.claims) }}
// Prevents concurrent login attempts (double-click or sign-in + sign-up race)let isAuthInProgress = false
async function runAuth(action) { if (isAuthInProgress) return isAuthInProgress = true if (els.signInBtn) els.signInBtn.disabled = true if (els.signUpBtn) els.signUpBtn.disabled = true safeSetText(els.claimsPre, "...") try { const res = await action() handleAuthResult(res) } finally { isAuthInProgress = false if (els.signInBtn) els.signInBtn.disabled = false if (els.signUpBtn) els.signUpBtn.disabled = false }}
els.signInBtn?.addEventListener("click", () => runAuth(() => window.kindeAuth.login()))els.signUpBtn?.addEventListener("click", () => runAuth(() => window.kindeAuth.register()))
els.getTokenBtn?.addEventListener("click", async () => { safeSetText(els.tokenPre, "...") const res = await window.kindeAuth.getAccessToken() safeSetText( els.tokenPre, res.ok ? res.access_token || "(no token)" : "Error: " + res.error )})
els.signOutLink?.addEventListener("click", async (e) => { e.preventDefault() await window.kindeAuth.logout() setAuthedUI(false)})
// Ensure DOM is ready, then bootstrapif (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bootstrap)} else { bootstrap()}To start your Electron application, run the following command in your terminal:
npm startThe Electron app window opens. Click Sign in or Sign up to begin the auth flow in your default browser.
Sign in or register a new account and test the auth flow. Once authenticated, you can see your profile, ID token claims, and retrieve an access token:
Go to your Kinde dashboard > Users to find the test user you created.
Once a user is authenticated, you can layer in Kinde’s authorization features to control what they can see and do. All of these are embedded in the verified access token payload — you’re reading claims you’ve already verified, so no extra API calls are needed.
The pattern for each feature is the same: set it up in the Kinde dashboard, add an IPC handler to main.js, expose it via preload.js, and consume it in the renderer.
Roles let you group users and gate access to features or entire areas of your app. A user can have one or more roles, and you assign them in the Kinde dashboard. When a user logs in, their roles travel in the access token roles claim — no extra API call needed.
Admin) and a Key (e.g. admin). The key is what you check in code.Add an IPC handler to main.js:
ipcMain.handle("auth:getRoles", async () => { try { const token = await getValidAccessToken() if (!token) return { ok: true, roles: [] } const payload = await verifyToken(token) return { ok: true, roles: payload.roles ?? [] } } catch (e) { return { ok: false, error: String(e) } }})Expose it in preload.js:
// preload.js — add to the contextBridge.exposeInMainWorld callgetRoles: () => ipcRenderer.invoke("auth:getRoles"),Check a role in the renderer:
const { ok, roles } = await window.kindeAuth.getRoles()if (ok) { const isAdmin = roles.some((r) => r.key === "admin") // show/hide admin UI based on isAdmin}Roles in the token payload look like:
[ { "id": "...", "key": "admin", "name": "Admin" }]Learn more about roles
Permissions are fine-grained access controls you define and assign to users, either directly or inherited through roles. They’re ideal for controlling specific actions — like exporting data or deleting records — independently of a user’s broader role. They appear in the access token permissions claim as a flat array of strings.
Export data) and a Key (e.g. export:data). The key is what you check in code.Add an IPC handler to main.js:
ipcMain.handle("auth:getPermissions", async () => { try { const token = await getValidAccessToken() if (!token) return { ok: true, permissions: [] } const payload = await verifyToken(token) return { ok: true, permissions: payload.permissions ?? [] } } catch (e) { return { ok: false, error: String(e) } }})Expose it in preload.js:
getPermissions: () => ipcRenderer.invoke("auth:getPermissions"),Check a specific permission in the renderer:
const { ok, permissions } = await window.kindeAuth.getPermissions()if (ok) { const canExport = permissions.includes("export:data") // enable/disable the export button based on canExport}Learn more about permissions
Feature flags let you toggle functionality for specific users or organizations without a code deploy or app update — useful for gradual rollouts, A/B tests, or per-customer configuration. They’re included in the access token as a feature_flags object, where each key maps to a type and a value.
dark_mode), and choose a Type: boolean, string, or integer.Add an IPC handler to main.js:
ipcMain.handle("auth:getFeatureFlags", async () => { try { const token = await getValidAccessToken() if (!token) return { ok: true, featureFlags: {} } const payload = await verifyToken(token) return { ok: true, featureFlags: payload.feature_flags ?? {} } } catch (e) { return { ok: false, error: String(e) } }})Expose it in preload.js:
getFeatureFlags: () => ipcRenderer.invoke("auth:getFeatureFlags"),Read flags in the renderer:
const { ok, featureFlags } = await window.kindeAuth.getFeatureFlags()if (ok) { const darkModeEnabled = featureFlags["dark_mode"]?.v === true const theme = featureFlags["theme"]?.v ?? "default" const maxItems = featureFlags["max_items"]?.v ?? 50}Feature flags in the payload look like:
{ "dark_mode": { "t": "b", "v": true }, "theme": { "t": "s", "v": "dark" }, "max_items": { "t": "i", "v": 10 }}Where t is the type (b = boolean, s = string, i = integer) and v is the value.
Learn more about feature flags
Organizations are how Kinde models multi-tenancy. Each organization is an isolated group of users with its own roles, permissions, and feature flag overrides — perfect for SaaS apps where each customer is a separate tenant. The user’s active organization is included in the access token as org_code.
org_code automatically.Add an IPC handler to main.js:
ipcMain.handle("auth:getOrganization", async () => { try { const token = await getValidAccessToken() if (!token) return { ok: true, org: null } const payload = await verifyToken(token) const org = payload.org_code ? { code: payload.org_code } : null return { ok: true, org } } catch (e) { return { ok: false, error: String(e) } }})Expose it in preload.js:
getOrganization: () => ipcRenderer.invoke("auth:getOrganization"),Use it in the renderer:
const { ok, org } = await window.kindeAuth.getOrganization()if (ok && org) { console.log("Current org:", org.code) // scope API requests or UI state to org.code}To log a user in under a specific organization, pass the org code when calling login or register from the renderer. It travels through the preload bridge and IPC handler into startLogin(), which appends it to the auth URL automatically:
await window.kindeAuth.login("org_abc123")// or for registration into a specific orgawait window.kindeAuth.register("org_abc123")Omitting the argument (or passing undefined) leaves the org code unset and Kinde uses its default behavior.
Learn more about organizations
Properties let you attach custom data to users, organizations, or applications, and surface that data as claims directly in the access token. This is the right tool when you need app-specific context on every request — things like subscription_tier, preferred_region, or account_type — without building a separate profile API call.
subscription_tier), and select a Data type (string, integer, or boolean).Once a property is included in the access token, it appears in the verified payload under the property key. Add an IPC handler to main.js:
ipcMain.handle("auth:getProperties", async () => { try { const token = await getValidAccessToken() if (!token) return { ok: true, properties: {} } const payload = await verifyToken(token) // Custom properties appear as top-level claims in the payload. // Replace the keys below with the property keys you defined in Kinde. const properties = { subscriptionTier: payload["subscription_tier"] ?? null, preferredRegion: payload["preferred_region"] ?? null, } return { ok: true, properties } } catch (e) { return { ok: false, error: String(e) } }})Expose it in preload.js:
getProperties: () => ipcRenderer.invoke("auth:getProperties"),Use it in the renderer:
const { ok, properties } = await window.kindeAuth.getProperties()if (ok) { const tier = properties.subscriptionTier // e.g. "pro" // show/hide features based on tier}Learn more about properties
The Kinde Management API is a server-side REST API that lets you manage your Kinde data programmatically — create and update users, assign or revoke roles, manage organization membership, update user properties, and more. It authenticates using OAuth 2.0 client credentials (an M2M app with a client secret), so it should only ever run in a backend you control, not inside the Electron app itself.
Use it when you need to perform admin-level operations in response to events in your app — for example, upgrading a user’s role when they complete a purchase, or adding them to an organization when they accept a team invite.
Learn more about the Kinde Management API
electron-builder is the standard tool for packaging Electron apps into distributable formats — a .dmg on macOS, an NSIS .exe installer on Windows, and an .AppImage on Linux.
Install electron-builder as a dev dependency:
npm install --save-dev electron-builderOpen package.json and replace it with the following to add build scripts and a build configuration block:
{ // ... other package.json content ... "scripts": { "start": "electron .", "dev": "electron .", "build": "electron-builder", "build:mac": "electron-builder --mac", "build:win": "electron-builder --win", "build:linux": "electron-builder --linux" }, "build": { "appId": "com.yourcompany.electron-kinde-auth", "productName": "Electron Kinde Auth", "files": [ "main.js", "preload.js", "helpers.js", "kinde.config.js", "renderer/**/*", "node_modules/**/*" ], "mac": { "target": "dmg", "category": "public.app-category.productivity" }, "win": { "target": "nsis" }, "linux": { "target": "AppImage" } }}Run the build command for your platform:
macOS:
npm run build:macWindows:
npm run build:winLinux:
npm run build:linuxThe packaged output appears in a dist/ folder.
kinde.config.js is bundled into the app
Since this guide uses PKCE (no client secret), the issuer URL and client ID are not sensitive — they’re equivalent to public app metadata embedded in the binary, the same way a mobile app bundles its client ID.
safeStorage and the keychain service name
safeStorage uses the app’s productName as the keychain service identifier on macOS. Make sure productName stays consistent between dev and production builds — changing it will make previously stored tokens unreadable to the new build.
App icon
Place your icon files in a build/ folder at the project root before running the build command. electron-builder picks them up automatically:
build/icon.icns — macOSbuild/icon.ico — Windowsbuild/icon.png — Linux (512×512 recommended)electron-builder includes electron-updater support out of the boxYou’ve built a production-ready Electron desktop app with Kinde authentication — PKCE OAuth flow, OS-native encrypted token storage via safeStorage, JWKS-backed JWT verification, silent token refresh, login timeout handling, concurrent login prevention, and session restoration on startup. The foundation is solid; you can now add features, integrate permissions, or package the app for distribution.