惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
V
Visual Studio Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
量子位
月光博客
月光博客
阮一峰的网络日志
阮一峰的网络日志
T
Tailwind CSS Blog
GbyAI
GbyAI
爱范儿
爱范儿
Y
Y Combinator Blog
宝玉的分享
宝玉的分享
有赞技术团队
有赞技术团队
罗磊的独立博客
Recent Announcements
Recent Announcements
博客园 - 司徒正美
M
MIT News - Artificial intelligence
小众软件
小众软件
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
B
Blog RSS Feed
A
About on SuperTechFans
Hugging Face - Blog
Hugging Face - Blog
Apple Machine Learning Research
Apple Machine Learning Research
雷峰网
雷峰网

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Building a Jira Time Tracker with Tauri: How I Stored API...
Vladimir Let · 2026-04-30 · via DEV Community

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.