Tauri Licensing
Add license key activation to your Tauri v2 app. Uses the Keymint Node.js SDK in the Rust backend via Tauri commands.
Problem
You built a Tauri desktop app and need license activation that binds to the user's machine. Since Tauri apps have a Rust backend and a web frontend, the licensing logic lives in Rust as Tauri commands. You need a secure, minimal-footprint way to activate, validate, and deactivate licenses without exposing API credentials to the webview.
Keymint's REST API works from any HTTP client. In a Tauri app, you call the API from Rust using reqwest, expose activation via Tauri commands, and keep your client API key in the compiled binary — not in the webview JavaScript.
Architecture Diagram
Create Product
From the Keymint Dashboard:
- Products → Create Product
- Name it after your Tauri app
- Copy the product ID
Create License Policy
When generating a license key, save your settings as a Template (click "Save current configuration as a template" in the Advanced tab).
| Setting | Value |
|---|---|
licenseType | node-locked |
maxActivations | 1 |
format | XXXX-XXXX-XXXX-XXXX |
Issue License
Generate keys from the Dashboard or using a backend script:
// In your admin backend
import { KeyMint } from 'keymint';
const admin = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
const license = await admin.createKey({
productId: "prod_Nx8K2mLpQ4rVtW9sBc",
maxActivations: 1,
licenseType: "node-locked",
newCustomer: { name: "Customer", email: "customer@example.com" },
});
console.log(license.key);Validate License
Add these dependencies to your Tauri project's src-tauri/Cargo.toml:
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
machine-uid = "0.5"
dirs = "5"Create the license command module:
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const API_BASE: &str = "https://api.keymint.dev";
const CLIENT_API_KEY: &str = env!("KEYMINT_CLIENT_API_KEY");
const PRODUCT_ID: &str = env!("KEYMINT_PRODUCT_ID");
#[derive(Deserialize)]
pub struct ActivateRequest {
#[serde(rename = "productId")]
product_id: String,
#[serde(rename = "licenseKey")]
license_key: String,
#[serde(rename = "hostId")]
host_id: String,
}
#[derive(Deserialize)]
pub struct ActivateResponse {
pub code: i32,
pub message: Option<String>,
}
#[derive(Deserialize)]
pub struct DeactivateResponse {
pub code: i32,
pub message: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct LicenseState {
pub license_key: String,
pub activated: bool,
}
fn license_file_path() -> PathBuf {
let dir = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("your-app-name");
fs::create_dir_all(&dir).ok();
dir.join("license.json")
}
fn read_state() -> Option<LicenseState> {
let path = license_file_path();
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
None
}
}
fn write_state(state: &LicenseState) {
let path = license_file_path();
if let Ok(json) = serde_json::to_string_pretty(state) {
fs::write(path, json).ok();
}
}
fn clear_state() {
let path = license_file_path();
fs::remove_file(path).ok();
}
#[tauri::command]
pub async fn activate_license(license_key: String) -> Result<String, String> {
let host_id = machine_uid::get().unwrap_or_else(|_| "unknown-host".to_string());
let client = reqwest::Client::new();
let response = client
.post(format!("{}/key/activate", API_BASE))
.header("Authorization", format!("Bearer {}", CLIENT_API_KEY))
.header("Content-Type", "application/json")
.json(&ActivateRequest {
product_id: PRODUCT_ID.to_string(),
license_key: license_key.clone(),
host_id,
})
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let result: ActivateResponse = response
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
if result.code == 0 {
write_state(&LicenseState {
license_key: license_key.clone(),
activated: true,
});
Ok(result.message.unwrap_or_else(|| "License valid".to_string()))
} else {
Err(result.message.unwrap_or_else(|| "Activation failed".to_string()))
}
}
#[tauri::command]
pub async fn deactivate_license() -> Result<String, String> {
let state = read_state().ok_or("No license found")?;
let host_id = machine_uid::get().unwrap_or_else(|_| "unknown-host".to_string());
let client = reqwest::Client::new();
let response = client
.post(format!("{}/key/deactivate", API_BASE))
.header("Authorization", format!("Bearer {}", CLIENT_API_KEY))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"productId": PRODUCT_ID,
"licenseKey": state.license_key,
"hostId": host_id,
}))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let result: DeactivateResponse = response
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
if result.code == 0 {
clear_state();
Ok("License deactivated".to_string())
} else {
Err(result.message.unwrap_or_else(|| "Deactivation failed".to_string()))
}
}
#[tauri::command]
pub fn is_activated() -> bool {
read_state().map(|s| s.activated).unwrap_or(false)
}
#[tauri::command]
pub fn get_license_status() -> serde_json::Value {
match read_state() {
Some(state) => serde_json::json!({
"activated": state.activated,
"licenseKey": mask_key(&state.license_key),
}),
None => serde_json::json!({ "activated": false, "licenseKey": null }),
}
}
fn mask_key(key: &str) -> String {
if key.len() <= 8 {
return "••••".to_string();
}
format!("{}••••{}", &key[..4], &key[key.len() - 4..])
}Register commands and set up the Tauri app:
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod license;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
license::activate_license,
license::deactivate_license,
license::is_activated,
license::get_license_status,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Set environment variables for build:
[env]
KEYMINT_CLIENT_API_KEY = "your_client_api_key_here"
KEYMINT_PRODUCT_ID = "prod_Nx8K2mLpQ4rVtW9sBc"Now build the webview activation UI:
<script>
import { invoke } from '@tauri-apps/api/core';
let licenseKey = '';
let status = '';
async function activate() {
status = 'Activating...';
try {
const message = await invoke('activate_license', {
licenseKey: licenseKey.trim()
});
status = message;
// License valid — navigate to main app
} catch (error) {
status = `Activation failed: ${error}`;
}
}
async function checkStatus() {
const s = await invoke('get_license_status');
status = s.activated ? 'License active' : 'Not activated';
}
</script>
<div>
<h2>Activate License</h2>
<input bind:value={licenseKey} placeholder="A8E2K-9F1BC-3D4GH-7J2KM" />
<button on:click={activate}>Activate</button>
<p>{status}</p>
</div>Handle Activations
The Rust backend handles all activation state. The activation count and device binding are enforced server-side by Keymint:
| Scenario | Behavior |
|---|---|
| First activation on new device | Seat consumed, activations incremented |
| Same host reactivates | Returns success, no new seat consumed |
New host under maxActivations | New seat consumed |
maxActivations exceeded | Returns error code 2: "Activation limit reached" |
| License expired | Returns error — activation rejected |
| License blocked (vendor disabled) | Returns error — activation rejected |
Handle Revocations
Block a license from the Dashboard or your backend:
import { KeyMint } from 'keymint';
const admin = new KeyMint(process.env.KEYMINT_ADMIN_API_KEY!);
await admin.blockKey({
productId: "prod_Nx8K2mLpQ4rVtW9sBc",
licenseKey: "A8E2K-9F1BC-3D4GH-7J2KM",
});Periodically re-validate in your Tauri app to catch revocations:
#[tauri::command]
pub async fn check_license_health() -> Result<bool, String> {
let state = match read_state() {
Some(s) => s,
None => return Ok(false),
};
let host_id = machine_uid::get().unwrap_or_else(|_| "unknown-host".to_string());
let client = reqwest::Client::new();
let response = client
.post(format!("{}/key/activate", API_BASE))
.header("Authorization", format!("Bearer {}", CLIENT_API_KEY))
.header("Content-Type", "application/json")
.json(&ActivateRequest {
product_id: PRODUCT_ID.to_string(),
license_key: state.license_key.clone(),
host_id,
})
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let result: ActivateResponse = response
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
if result.code != 0 {
clear_state();
}
Ok(result.code == 0)
}Production Considerations
API Key Security
KEYMINT_CLIENT_API_KEYis compiled into the Rust binary viaenv!()— it is not visible in the webview JavaScript- Use
clientscope only. This key can activate, deactivate, and manage floating sessions. It cannot read customer data or manage other licenses - Never embed
adminAPI keys in the binary
Host ID Stability
The machine-uid crate uses /etc/machine-id on Linux, IOPlatformUUID on macOS, and MachineGuid from the registry on Windows. These are stable and survive most system changes.
Build Configuration
Store the product ID and client API key as build-time environment variables. Never hardcode them in source files that might be committed:
KEYMINT_CLIENT_API_KEY=km_client_abc123...
KEYMINT_PRODUCT_ID=prod_xyz789...#!/bin/bash
source .env
cargo tauri buildFloating License Support
If your Tauri app needs concurrent seat licensing (e.g. team plan), use floating licenses:
// POST /key/checkout — lease a seat
// Returns: { sessionId, sessionSecret, nextNonce, expiresAt, heartbeatInterval }
// POST /key/heartbeat — extend the lease
// Requires HMAC-SHA256(sessionId:nonce, sessionSecret) signature
// POST /key/checkin — release the seatSee Floating Licenses for the full workflow.
Offline Support
For air-gapped deployments, use Keymint offline signing (available on all plans):
// Run from your backend
const signedFile = await admin.signOfflineKey({
productId: "prod_Nx8K2mLpQ4rVtW9sBc",
licenseKey: "A8E2K-9F1BC-3D4GH-7J2KM",
hostId: "machine-fingerprint",
ttl: 60 * 60 * 24 * 30, // 30 days
});
// signedFile.file is a JSON string with the signed JWTVerify offline using Rust crates like jsonwebtoken with Ed25519 support.
Complete Source Code
Full src-tauri/src/license.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const API_BASE: &str = "https://api.keymint.dev";
const CLIENT_API_KEY: &str = env!("KEYMINT_CLIENT_API_KEY");
const PRODUCT_ID: &str = env!("KEYMINT_PRODUCT_ID");
#[derive(Deserialize)]
struct ActivateRequest {
#[serde(rename = "productId")]
product_id: String,
#[serde(rename = "licenseKey")]
license_key: String,
#[serde(rename = "hostId")]
host_id: String,
}
#[derive(Deserialize)]
struct ApiResponse {
code: i32,
message: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct LicenseState {
license_key: String,
activated: bool,
}
fn license_file_path() -> PathBuf {
let dir = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("your-app");
fs::create_dir_all(&dir).ok();
dir.join("license.json")
}
fn read_state() -> Option<LicenseState> {
let path = license_file_path();
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
None
}
}
fn write_state(state: &LicenseState) {
let path = license_file_path();
if let Ok(json) = serde_json::to_string_pretty(state) {
fs::write(path, json).ok();
}
}
fn clear_state() {
fs::remove_file(license_file_path()).ok();
}
#[tauri::command]
pub async fn activate_license(license_key: String) -> Result<String, String> {
let host_id = machine_uid::get().unwrap_or_else(|_| "unknown-host".to_string());
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/key/activate", API_BASE))
.header("Authorization", format!("Bearer {}", CLIENT_API_KEY))
.header("Content-Type", "application/json")
.json(&ActivateRequest {
product_id: PRODUCT_ID.to_string(),
license_key: license_key.clone(),
host_id,
})
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let result: ApiResponse = resp.json().await.map_err(|e| format!("Invalid response: {}", e))?;
if result.code == 0 {
write_state(&LicenseState { license_key, activated: true });
Ok(result.message.unwrap_or_else(|| "License valid".to_string()))
} else {
Err(result.message.unwrap_or_else(|| "Activation failed".to_string()))
}
}
#[tauri::command]
pub async fn deactivate_license() -> Result<String, String> {
let state = read_state().ok_or("No license found")?;
let host_id = machine_uid::get().unwrap_or_else(|_| "unknown-host".to_string());
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/key/deactivate", API_BASE))
.header("Authorization", format!("Bearer {}", CLIENT_API_KEY))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"productId": PRODUCT_ID,
"licenseKey": state.license_key,
"hostId": host_id,
}))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let result: ApiResponse = resp.json().await.map_err(|e| format!("Invalid response: {}", e))?;
if result.code == 0 {
clear_state();
Ok("License deactivated".to_string())
} else {
Err(result.message.unwrap_or_else(|| "Deactivation failed".to_string()))
}
}
#[tauri::command]
pub fn is_activated() -> bool {
read_state().map(|s| s.activated).unwrap_or(false)
}
#[tauri::command]
pub fn get_license_status() -> serde_json::Value {
match read_state() {
Some(s) => serde_json::json!({
"activated": s.activated,
"licenseKey": format!("{}••••{}", &s.license_key[..4.min(s.license_key.len())], &s.license_key[s.license_key.len().saturating_sub(4)..]),
}),
None => serde_json::json!({ "activated": false, "licenseKey": null }),
}
}