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

mermaid
Rendering diagram...

Create Product

From the Keymint Dashboard:

  1. ProductsCreate Product
  2. Name it after your Tauri app
  3. 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).

SettingValue
licenseTypenode-locked
maxActivations1
formatXXXX-XXXX-XXXX-XXXX

Issue License

Generate keys from the Dashboard or using a backend script:

typescript
// 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:

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:

rust
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:

rust
// 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:

bash
[env]
KEYMINT_CLIENT_API_KEY = "your_client_api_key_here"
KEYMINT_PRODUCT_ID = "prod_Nx8K2mLpQ4rVtW9sBc"

Now build the webview activation UI:

html
<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:

ScenarioBehavior
First activation on new deviceSeat consumed, activations incremented
Same host reactivatesReturns success, no new seat consumed
New host under maxActivationsNew seat consumed
maxActivations exceededReturns error code 2: "Activation limit reached"
License expiredReturns error — activation rejected
License blocked (vendor disabled)Returns error — activation rejected

Handle Revocations

Block a license from the Dashboard or your backend:

typescript
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:

rust
#[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_KEY is compiled into the Rust binary via env!() — it is not visible in the webview JavaScript
  • Use client scope only. This key can activate, deactivate, and manage floating sessions. It cannot read customer data or manage other licenses
  • Never embed admin API 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:

bash
KEYMINT_CLIENT_API_KEY=km_client_abc123...
KEYMINT_PRODUCT_ID=prod_xyz789...
bash
#!/bin/bash
source .env
cargo tauri build

Floating License Support

If your Tauri app needs concurrent seat licensing (e.g. team plan), use floating licenses:

rust
// 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 seat

See Floating Licenses for the full workflow.

Offline Support

For air-gapped deployments, use Keymint offline signing (available on all plans):

typescript
// 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 JWT

Verify offline using Rust crates like jsonwebtoken with Ed25519 support.

Complete Source Code

Full src-tauri/src/license.rs
rust
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 }),
    }
}