DEV Community

Cover image for Storing a Gemini API Key Securely in a Tauri App — Don't Hardcode It
hiyoyo
hiyoyo

Posted on

Storing a Gemini API Key Securely in a Tauri App — Don't Hardcode It

All tests run on an 8-year-old MacBook Air.

Every tutorial for Gemini integration shows this:

const API_KEY = "AIzaSy...";
Enter fullscreen mode Exit fullscreen mode

Hardcoded in the frontend. Shipped in the binary. Readable by anyone who opens the app bundle.

For a desktop app, you can do better. Here's how HiyokoLogcat stores the Gemini API key.


The problem with hardcoding

A Tauri app ships as a DMG. The frontend JS is bundled inside the app package. Anyone can unzip the DMG, dig into the resources folder, and read your API key.

Even if you obfuscate it — someone will find it. Security through obscurity isn't security.

The right approach: let the user provide their own API key, store it in the OS keychain or an encrypted local store.


Option 1: tauri-plugin-store (simple)

For most use cases, tauri-plugin-store is enough. It stores data in an encrypted JSON file on the user's machine:

[dependencies]
tauri-plugin-store = "2"
Enter fullscreen mode Exit fullscreen mode
use tauri_plugin_store::StoreExt;

#[tauri::command]
pub fn save_api_key(app: tauri::AppHandle, key: String) -> Result<(), String> {
    let store = app.store("config.json").map_err(|e| e.to_string())?;
    store.set("gemini_api_key", key);
    store.save().map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command]
pub fn load_api_key(app: tauri::AppHandle) -> Result, String> {
    let store = app.store("config.json").map_err(|e| e.to_string())?;
    let key = store.get("gemini_api_key")
        .and_then(|v| v.as_str().map(String::from));
    Ok(key)
}
Enter fullscreen mode Exit fullscreen mode

The key lives in ~/Library/Application Support/[your-app]/config.json, encrypted at rest.


Option 2: macOS Keychain (more secure)

For higher security requirements, store in the macOS Keychain via the security CLI:

use std::process::Command;

pub fn save_to_keychain(service: &str, key: &str) -> Result<(), String> {
    Command::new("security")
        .args(["add-generic-password",
            "-s", service,
            "-a", "gemini_api_key",
            "-w", key,
            "-U"])  // -U updates if exists
        .output()
        .map_err(|e| e.to_string())?;
    Ok(())
}

pub fn load_from_keychain(service: &str) -> Result {
    let output = Command::new("security")
        .args(["find-generic-password",
            "-s", service,
            "-a", "gemini_api_key",
            "-w"])  // -w prints password only
        .output()
        .map_err(|e| e.to_string())?;

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
Enter fullscreen mode Exit fullscreen mode

Keychain storage survives app reinstalls and is protected by the user's login password.


What I shipped

HiyokoLogcat uses tauri-plugin-store. The settings screen has a password-type input for the API key — the user pastes their key from Google AI Studio, hits save, done.

The key never appears in the UI after initial entry. The Rust backend reads it directly when making API calls — it never surfaces to the frontend JavaScript.


The user experience

"Get a free API key from Google AI Studio, paste it here."

That's it. Users understand this. It takes 2 minutes. And now they own their API key — your app doesn't.


HiyokoLogcat is free and open source → github.com/hiyoyok/HiyokoLogcat
X → @hiyoyok

Top comments (0)