Skip to content
  • SDKs and APIs
  • Special guides

Use Kinde auth in an Electron desktop app

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.

  • A Kinde account with Admin or Engineer access (Sign up for free)
  • Node.js version 20+
  • Knowledge of JavaScript, HTML, and CSS

1. Create a Kinde application

Link to this section
  1. Go to your Kinde dashboard, select Add new application
  2. Set a name for your application, select Front-end and mobile and select Save.
  3. Select Other native and select Save.
  4. Go to Details page and copy the Domain (or custom domain), and Client ID values.
  5. Add Callback URLs:
    • Allowed callback URLs: http://127.0.0.1:53180/callback.
    • Allowed logout redirect URLs: http://127.0.0.1:53180/logout-complete.
  6. Select Save.
  7. Go to Authentication and enable the auth methods you want your users to sign in (e.g., Google, Facebook, etc.).
  8. Select Save.

2. Initialize an Electron app

Link to this section
  1. Create a new project with the terminal command:

    Terminal window
    mkdir electron-kinde-auth
    cd electron-kinde-auth
  2. Create a new package.json file and add the following:

    Terminal window
    touch package.json
    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"
    }
    }
  3. Run the following command to install the required packages:

    Terminal window
    npm install
  4. Create a .gitignore file to keep generated files out of version control:

    Terminal window
    touch .gitignore
    echo "node_modules/" >> .gitignore
    echo "dist/" >> .gitignore
  5. Create a kinde.config.js file for your Kinde credentials and add the Domain and Client ID copied from the Kinde Details page:

    Terminal window
    touch kinde.config.js
    kinde.config.js
    module.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.

3. Create a helper file

Link to this section
  1. Create a new helpers.js file and add the following:

    Terminal window
    touch helpers.js
    helpers.js
    const 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 decisions
    function 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,
    }

4. Create the main process file

Link to this section
  1. Create a new main.js file and add the following:

    Terminal window
    touch main.js
    main.js
    const { 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 = 53180
    const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/callback`
    const LOGOUT_REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/logout-complete`
    const ISSUER = config.issuerUrl
    const CLIENT_ID = config.clientId
    const 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 win
    function 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) }
    }
    })

5. Create the preload file

Link to this section
  1. Create a new preload.js file and add the following:

    Terminal window
    touch preload.js
    preload.js
    const { 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"),
    })

6. Create the renderer process file

Link to this section
  1. Run the following to set up your project structure:

    Terminal window
    mkdir renderer
    touch renderer/index.html renderer/style.css renderer/renderer.js
  2. Add the following code to index.html file to create the UI:

    renderer/index.html
    <!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&rsquo;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"
    >&copy; 2026 KindeAuth, Inc. All rights reserved</small
    >
    </div>
    </footer>
    <script src="./renderer.js"></script>
    </body>
    </html>
  3. Add the following code to style.css file to style the UI.

    renderer/style.css
    :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;
    }
  4. Add the following code to renderer.js file to make everything work.

    renderer/renderer.js
    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 bootstrap
    if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", bootstrap)
    } else {
    bootstrap()
    }

7. Test user authentication

Link to this section
  1. To start your Electron application, run the following command in your terminal:

    Terminal window
    npm start

    The Electron app window opens. Click Sign in or Sign up to begin the auth flow in your default browser.

    Electron app with kinde auth

  2. 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:

    Signed in to electron app

  3. Go to your Kinde dashboard > Users to find the test user you created.

Kinde features

Link to this section

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.

Set up roles in Kinde

Link to this section
  1. Go to your Kinde dashboard and select Settings > User management > Roles.
  2. Select Add role.
  3. Enter a Name (e.g. Admin) and a Key (e.g. admin). The key is what you check in code.
  4. Select Save.
  5. To assign the role to a user, go to Users, select a user, open their Roles tab, and assign the role.

Use roles in your Electron app

Link to this section

Add an IPC handler to main.js:

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 call
getRoles: () => ipcRenderer.invoke("auth:getRoles"),

Check a role in the renderer:

renderer/renderer.js
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.

Set up permissions in Kinde

Link to this section
  1. Go to your Kinde dashboard and select Settings > User management > Permissions.
  2. Select Add permission.
  3. Enter a Name (e.g. Export data) and a Key (e.g. export:data). The key is what you check in code.
  4. Select Save.
  5. To attach the permission to a role, go to Settings > Roles, select a role, open its Permissions tab, and add the permission. Users assigned that role will inherit it automatically.

Use permissions in your Electron app

Link to this section

Add an IPC handler to main.js:

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:

preload.js
getPermissions: () => ipcRenderer.invoke("auth:getPermissions"),

Check a specific permission in the renderer:

renderer/renderer.js
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.

Set up feature flags in Kinde

Link to this section
  1. Go to your Kinde dashboard and select Releases > Feature flags.
  2. Select Add feature flag.
  3. Enter a Name, a Key (e.g. dark_mode), and choose a Type: boolean, string, or integer.
  4. Set the Default value — this applies to all users unless overridden.
  5. Select Save.
  6. To set a value for a specific user, go to Users, select a user, open their Feature flags tab, and add an override.

Use feature flags in your Electron app

Link to this section

Add an IPC handler to main.js:

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:

preload.js
getFeatureFlags: () => ipcRenderer.invoke("auth:getFeatureFlags"),

Read flags in the renderer:

renderer/renderer.js
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.

Set up organizations in Kinde

Link to this section
  1. Go to your Kinde dashboard and select Organizations.
  2. Select Add organization.
  3. Enter a Name for the organization and select Save. Kinde generates a unique org_code automatically.
  4. To add users to the organization, go to Users, select a user, open their Organizations tab, and add the organization.

Use organizations in your Electron app

Link to this section

Add an IPC handler to main.js:

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:

preload.js
getOrganization: () => ipcRenderer.invoke("auth:getOrganization"),

Use it in the renderer:

renderer/renderer.js
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:

renderer/renderer.js
await window.kindeAuth.login("org_abc123")
// or for registration into a specific org
await 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.

Set up properties in Kinde

Link to this section
  1. Go to your Kinde dashboard and select Settings > Data management > Properties.
  2. Select Add property.
  3. Choose the context (User, Organization, or Application), enter a Name and Key (e.g. subscription_tier), and select a Data type (string, integer, or boolean).
  4. Set the property visibility to Public — only public properties can be included in tokens.
  5. Select Save.
  6. To include the property in your app’s access token, go to Settings > Applications, open your application, and select Tokens. Under Token customization, select Customize on the Access token, tick the property, and select Save.
  7. To set a value for a user, go to Users, select the user, open their Properties tab, and set the value.

Use properties in your Electron app

Link to this section

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:

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:

preload.js
getProperties: () => ipcRenderer.invoke("auth:getProperties"),

Use it in the renderer:

renderer/renderer.js
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

Management API

Link to this section

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

Build for production

Link to this section

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.

  1. Install electron-builder as a dev dependency:

    Terminal window
    npm install --save-dev electron-builder
  2. Open package.json and replace it with the following to add build scripts and a build configuration block:

    package.json
    {
    // ... 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"
    }
    }
    }
  3. Run the build command for your platform:

    macOS:

    Terminal window
    npm run build:mac

    Windows:

    Terminal window
    npm run build:win

    Linux:

    Terminal window
    npm run build:linux

    The packaged output appears in a dist/ folder.

Important notes for production builds

Link to this section

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 — macOS
  • build/icon.ico — Windows
  • build/icon.png — Linux (512×512 recommended)

Electron distribution

Link to this section

You’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.