DEV Community

Cover image for Building a Jira Time Tracker with Tauri: How I Stored API Tokens Securely
Vladimir Letiagin
Vladimir Letiagin

Posted on

Building a Jira Time Tracker with Tauri: How I Stored API Tokens Securely

I've been building a small menu-bar app for tracking time on Jira issues. Mostly it's boring CRUD: a timer, a list of issues, push worklogs back to Jira. Except for one thing I had to figure out on day one. The user logs in with a Jira API token, and that token has to live somewhere on their machine.

Where, exactly?

Every Tauri tutorial skips this part. Below is what I tried, what broke, and what I shipped.

My first attempt (don't do this)

I already had a local SQLite database in the app for caching Jira issues, worklogs, and user prefs. So my first move was the obvious one — stick the API token in a settings table next to everything else. One database, one place to look, simple.

Don't do this.

SQLite stores everything in a plain .db file on disk. No encryption by default. Anyone with file access on the machine reads your token in two seconds:

sqlite3 app.db "SELECT value FROM settings WHERE key='api_token'"
Enter fullscreen mode Exit fullscreen mode

Backups, iCloud sync, Dropbox, malware scanners — all see it. Same problem with localStorage in the Tauri webview, with a JSON file in the app data dir, or with convenience plugins like tauri-plugin-store. Plain text on disk is plain text on disk. And if your frontend has any XSS-shaped bug, the token is right there for a script to grab.

What you actually want is the OS credential store. Every desktop OS ships with one. They exist specifically to keep secrets off plain disk and away from other users on the machine.

What Tauri suggests

Tauri's official docs point you at the Stronghold plugin. It uses IOTA's Stronghold engine, encrypts everything with a password, and writes to a snapshot file.

Stronghold solves a different problem — a full vault with key derivation, multiple records, the whole deal. For a single API token it's the wrong shape. It also needs the user to enter a password to unlock the vault, or you stash that password somewhere, and now you've moved the problem one level up. Where do you store the password that unlocks Stronghold?

I wanted the OS to handle the trust. That means the system keychain.

What I actually used: the keyring crate

The keyring crate is a Rust wrapper that gives you one API on top of:

  • macOS: Keychain Services
  • Windows: Credential Manager
  • Linux: Secret Service API (gnome-keyring, KWallet, etc.)

You write the code once, and on each platform it talks to the native store. Users get the same security guarantees they expect from Safari saving a password or git saving a credential.

Heads up: keyring 3.x is a major bump from 2.x with breaking API changes. Half the tutorials you'll find on Google are still on the old version, so check the version in their Cargo.toml before copy-pasting.

Cargo.toml:

[dependencies]
keyring = { version = "3", features = [
    "apple-native",
    "windows-native",
    "sync-secret-service",
    "crypto-rust",
] }
Enter fullscreen mode Exit fullscreen mode

Those features matter. apple-native and windows-native use the platform APIs directly. sync-secret-service is the Linux backend. crypto-rust handles the D-Bus transport encryption in pure Rust so you don't drag in an OpenSSL dependency.

The whole storage module

This is the entire secure_store.rs from my app. There's nothing more to it:

use keyring::Entry;

const SERVICE: &str = "planim-time-tracker";

pub fn set_secret(key: &str, value: &str) -> Result<(), String> {
    Entry::new(SERVICE, key)
        .map_err(|e| format!("Keyring entry error: {e}"))?
        .set_password(value)
        .map_err(|e| format!("Failed to save to keyring: {e}"))
}

pub fn get_secret(key: &str) -> Result<Option<String>, String> {
    match Entry::new(SERVICE, key)
        .map_err(|e| format!("Keyring entry error: {e}"))?
        .get_password()
    {
        Ok(v) => Ok(Some(v)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(e) => Err(format!("Failed to read from keyring: {e}")),
    }
}

pub fn delete_secret(key: &str) -> Result<(), String> {
    match Entry::new(SERVICE, key)
        .map_err(|e| format!("Keyring entry error: {e}"))?
        .delete_credential()
    {
        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
        Err(e) => Err(format!("Failed to delete from keyring: {e}")),
    }
}
Enter fullscreen mode Exit fullscreen mode

Three functions, no state, no init. The SERVICE constant is what shows up as the entry name in Keychain Access on macOS or Credential Manager on Windows. Pick a stable name and don't change it later — if you do, every existing user loses their saved credentials and has to re-auth.

One thing worth being explicit about: this is for secrets only. Tokens, refresh tokens, passwords. User preferences, base URLs, UI state — that all goes in SQLite or wherever else makes sense. Keychain entries are slow-ish to read and have size limits, so don't dump everything in there.

Wiring it into a Tauri command

I never touch secure_store from the frontend. The frontend asks for an action, the backend handles the secret. The Jira API token never leaves the Rust side once it's saved:

#[tauri::command]
pub async fn settings_save_jira_config(
    state: State<'_, AppState>,
    base_url: String,
    email: String,
    api_token: String,
) -> Result<(), String> {
    // non-secret fields go in SQLite
    // ...
    secure_store::set_secret("jira_api_token", &api_token)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The pattern: secrets in keyring, everything else (Jira base URL, email, preferences) in SQLite. The frontend never holds the token in JS state for longer than a single form submission.

Per-OS gotchas you'll only find out about in production

This is the part nobody mentions until it bites you.

macOS

The first time another binary tries to read a Keychain item, the user gets an "allow access" prompt — three buttons: Always Allow, Allow, Deny. No password input. A lot of devs avoid Keychain because they think it nags users for their macOS password every time. It doesn't, as long as you put items in the login keychain (which is what keyring does by default). The login keychain is unlocked automatically when the user signs into the Mac, and stays unlocked.

The catch: Keychain identifies your app by its code signature. If the signature changes, Keychain treats it as a new app and prompts again. In development, every cargo build produces a new ad-hoc signature, so you see the "allow access" prompt after every rebuild. Annoying, but it goes away in production as long as you sign with a stable Developer ID. The signature stays consistent across app updates, and Always Allow sticks.

If you ship unsigned, you have a bigger problem than Keychain prompts: macOS 15+ makes it deeply painful for users to launch your app. The right-click → Open trick is gone in Sequoia — users now have to dig into System Settings → Privacy & Security and explicitly allow the binary every time. Sign your app. The Apple Developer Program is $99/year and worth it for any real desktop app.

Notarization, by the way, doesn't affect Keychain at all. That's only for Gatekeeper letting users open downloaded .dmg files.

There's also a quirk where if you change your bundle identifier or developer team, Keychain treats the new build as a different app. Pre-existing entries from old builds stay in the user's Keychain forever unless you clean them up. On uninstall, run delete_credential for every key your app stores.

Windows

Credential Manager is per-user. No prompt at all, ever. Items are protected by the user's Windows login. Easy mode.

The catch: if a user logs into Windows with a different account, they don't see those credentials. Which is what you want, but it occasionally confuses users with multiple Windows accounts on the same machine.

Linux

This is where it gets messy. The Secret Service API needs an active D-Bus session and a running provider, usually gnome-keyring-daemon or kwalletmanager. On a clean GNOME or KDE desktop, no problem. On a minimal i3 or sway setup, or in a CI container, there's no daemon running, and keyring returns an error.

I handle that case explicitly. If the keyring lookup fails on Linux, I show the user a message asking them to install gnome-keyring. Most desktop users won't hit it, but power users on bare WMs will, and silently failing is the worst possible UX.

For headless or server use, the keyring crate has a mock feature for tests, and there are file-based fallbacks. I didn't ship those because the app is desktop-only.

What I'd skip if I started over

A few things I tried first and threw away:

Encrypting a JSON file with a hardcoded key in the binary. Anyone with a hex editor pulls that key out. Pointless.

Stronghold. Not because it's bad — it's a serious piece of crypto engineering. But for a single API token, it added a password-unlock flow my users didn't want. They expected the OS to remember them, the way every other app does.

See it in production

The app I built on top of all this is time.planim.app. One-click timers on Jira issues, automatic worklog sync, calendar view, custom JQL filters — all without leaving the menu bar.

If you went the Stronghold route, or built some hybrid setup, or solved this differently on Linux — drop your approach in the comments. Curious what trade-offs other people made.

Top comments (0)