Documentation menu▾

Tauri

License key activation for Tauri 2: a Rust module that activates keys, verifies Ed25519 tokens offline and exposes two commands to the frontend.

This guide adds license keys to a Tauri 2 app. The licensing code lives in Rust, where it is compiled into the binary and kept away from the web frontend. The frontend only calls two commands: one to check the license on startup and one to activate a key. For the concepts behind each step, see Activating licenses in your app.

Dependencies

Add these to src-tauri/Cargo.toml. machine-uid reads the operating system's machine ID, which keeps the device the same across restarts and reinstalls.

src-tauri/Cargo.toml
[dependencies]
base64 = "0.22"
ed25519-dalek = "2"
hex = "0.4"
machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"

The license module

Fill in your server address and the public key from /api/v1/license/pubkey:

src-tauri/src/license.rs
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::{fs, path::Path, time::SystemTime};

const SERVER: &str = "https://licenses.example.com";
const PUBLIC_KEY_HEX: &str = "3b6a27bc..."; // from /api/v1/license/pubkey
const OFFLINE_DAYS: i64 = 7; // how long to keep working when the server cannot be reached

#[derive(Serialize, Deserialize)]
struct Saved {
    license_key: String,
    token: String,
}

#[derive(Serialize, Deserialize)]
pub struct Claims {
    pub lid: String,
    pub did: String,
    pub exp: i64,
    #[serde(default)]
    pub ftr: Map<String, Value>,
}

// The OS machine ID, hashed with SHA256, so it is stable but not readable.
fn identifier() -> String {
    let id = machine_uid::get().unwrap_or_default();
    hex::encode(Sha256::digest(id.as_bytes()))
}

fn now() -> i64 {
    SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64
}

fn read_token(token: &str) -> Option<Claims> {
    let (payload, signature) = token.split_once('.')?;
    let key: [u8; 32] = hex::decode(PUBLIC_KEY_HEX).ok()?.try_into().ok()?;
    let signature: [u8; 64] = URL_SAFE_NO_PAD.decode(signature).ok()?.try_into().ok()?;
    VerifyingKey::from_bytes(&key)
        .ok()?
        .verify(payload.as_bytes(), &Signature::from_bytes(&signature))
        .ok()?;
    let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
    (claims.did == identifier()).then_some(claims)
}

// Calls the license API. The error is the HTTP status (0 when offline) and the error code.
async fn call(action: &str, mut body: Value) -> Result<String, (u16, String)> {
    body["identifier"] = json!(identifier());
    let res = reqwest::Client::new()
        .post(format!("{SERVER}/api/v1/license/{action}"))
        .json(&body)
        .send()
        .await
        .map_err(|_| (0, "OFFLINE".to_string()))?;
    let status = res.status().as_u16();
    let json: Value = res.json().await.map_err(|_| (status, "BAD_RESPONSE".to_string()))?;
    match json["data"]["token"].as_str() {
        Some(token) if status == 200 => Ok(token.to_string()),
        _ => Err((status, json["error"]["code"].as_str().unwrap_or("UNKNOWN").to_string())),
    }
}

fn save(dir: &Path, saved: &Saved) {
    let _ = fs::create_dir_all(dir);
    let _ = fs::write(dir.join("license.json"), serde_json::to_vec(saved).unwrap());
}

pub async fn activate(dir: &Path, license_key: String) -> Result<Claims, String> {
    let token = call("activate", json!({ "license_key": license_key }))
        .await
        .map_err(|(_, code)| code)?;
    let claims = read_token(&token).ok_or("INVALID_TOKEN")?;
    save(dir, &Saved { license_key, token });
    Ok(claims)
}

// Returns the license claims, or None when the app is not licensed.
pub async fn check(dir: &Path) -> Option<Claims> {
    let file = dir.join("license.json");
    let saved: Saved = serde_json::from_slice(&fs::read(&file).ok()?).ok()?;
    let claims = read_token(&saved.token);
    if claims.as_ref().is_some_and(|c| c.exp > now()) {
        return claims;
    }
    match call("verify", json!({ "license_key": saved.license_key })).await {
        Ok(token) => {
            let claims = read_token(&token);
            save(dir, &Saved { license_key: saved.license_key, token });
            claims
        }
        Err((404, _)) => {
            let _ = fs::remove_file(file);
            None
        }
        // No connection: trust the last good token for a few more days.
        Err(_) => claims.filter(|c| c.exp + OFFLINE_DAYS * 86400 > now()),
    }
}

check works without a network connection as long as the saved token is fresh. When it is due, it asks the server again and saves the new token. If the server says the license no longer works on this device, the saved key is removed; if the server cannot be reached, the app keeps working for OFFLINE_DAYS longer.

Commands

Expose the module to the frontend as two commands. The license is saved in the app's data folder.

src-tauri/src/lib.rs
mod license;
use tauri::Manager;

#[tauri::command]
async fn activate_license(app: tauri::AppHandle, key: String) -> Result<license::Claims, String> {
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
    license::activate(&dir, key).await
}

#[tauri::command]
async fn check_license(app: tauri::AppHandle) -> Option<license::Claims> {
    let dir = app.path().app_data_dir().ok()?;
    license::check(&dir).await
}

pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![activate_license, check_license])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Calling it from the frontend

javascript
import { invoke } from "@tauri-apps/api/core";

const license = await invoke("check_license");
if (!license) showActivation();

// In the activation form:
try {
  await invoke("activate_license", { key });
  showApp();
} catch (code) {
  showError(code); // for example ACTIVATION_LIMIT
}

A failed activation rejects with the Keygate error code, or OFFLINE when the server cannot be reached. The error table lists what to tell the customer for each one. license.ftr holds the plan's features, so the app can turn parts of itself on and off. See Features and usage limits.

Before you ship

  • Test with a real license from your dashboard, including what happens when you deactivate the device or revoke the license.
  • Keygate can serve the update feed for the Tauri updater, so only licensed customers receive new versions. See Shipping updates.

Last updated October 4, 2026