Tauri confuses people on CORS for one simple reason: it looks like a web app, but part of it behaves like a native app. That split changes what CORS does, where it applies, and how much protection you really get.

If you build for the web first, your instinct is usually: “I’ll just fetch() the API from the frontend.” In Tauri, that can be correct, wrong, insecure, or just annoying depending on which runtime path you choose.

Here’s the practical comparison.

The short version

In Tauri, you usually have three ways to talk to an API:

  1. Browser-side fetch from the WebView
  2. Rust backend making the request
  3. A Tauri HTTP plugin or native-side request bridge

The big difference is this:

  • WebView requests are generally subject to browser CORS rules
  • Rust/native requests are not browser-origin requests, so CORS usually does not apply the same way
  • Moving requests to native can “fix” CORS errors, but it also removes a browser security boundary

That last point is where people get burned.

Option 1: Fetch from the Tauri frontend

This is the most familiar model. Your frontend code does something like this:

const res = await fetch("https://api.github.com/repos/tauri-apps/tauri", {
  headers: {
    Accept: "application/vnd.github+json"
  }
});

const data = await res.json();
console.log(data);

If that request is made from the WebView, CORS behavior depends on the WebView engine and the origin your app is running under. In practice, you should assume browser-style CORS restrictions matter unless Tauri explicitly routes the request through native code.

Pros

  • Simple mental model if you come from web development
  • Browser enforces cross-origin restrictions for you
  • Easier to reason about credentials, cookies, and frontend networking behavior
  • Fewer surprises when sharing code between Tauri and a normal web app

Cons

  • You can hit classic CORS failures
  • Preflight requests can break things you thought were trivial
  • Some desktop-specific origins can behave weirdly compared to a normal https://app.example.com
  • You depend on the target API sending the right headers

For example, GitHub’s API sends:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That access-control-allow-origin: * means a plain cross-origin read is allowed for public requests. The access-control-expose-headers list matters too. Without it, your frontend JavaScript would not be able to read many of those non-simple response headers.

Example:

const res = await fetch("https://api.github.com/rate_limit");
console.log(res.headers.get("x-ratelimit-remaining")); // readable because exposed
console.log(res.headers.get("etag")); // readable because exposed

That’s the “good citizen API” case. Plenty of APIs are not this clean.

Option 2: Make the request in Rust

This is the usual escape hatch when CORS blocks frontend requests.

Instead of fetching in the WebView, define a Tauri command:

#[tauri::command]
async fn get_user() -> Result<String, String> {
    let response = reqwest::get("https://api.example.com/user")
        .await
        .map_err(|e| e.to_string())?;

    let body = response.text().await.map_err(|e| e.to_string())?;
    Ok(body)
}

Then call it from the frontend:

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

const body = await invoke<string>("get_user");
console.log(JSON.parse(body));

This usually bypasses browser CORS pain because the request is no longer being made by browser JavaScript as a cross-origin fetch. It’s just your app making an outbound network request.

Pros

  • No browser preflight headaches
  • Works with APIs that do not send CORS headers
  • You can keep secrets out of frontend JavaScript
  • Better place for auth signing, token refresh, and request normalization

Cons

  • Easy to turn your desktop app into a confused-deputy proxy
  • You lose browser-origin protections
  • Any XSS in the frontend may now gain access to native-powered network capabilities
  • More code and more trust in your command boundary

This is the part where I get opinionated: bypassing CORS is not automatically a win. CORS is annoying, but it also stops random frontend code from reading arbitrary cross-origin responses. Once you move that logic into Rust, you become the policy layer.

If your command accepts arbitrary URLs, you’ve built an SSRF-like primitive into your desktop app.

Bad idea:

#[tauri::command]
async fn fetch_any(url: String) -> Result<String, String> {
    let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
    response.text().await.map_err(|e| e.to_string())
}

If the frontend gets compromised, an attacker may be able to probe internal services, localhost admin panels, cloud metadata endpoints, or anything else reachable from the machine.

Safer pattern:

#[tauri::command]
async fn fetch_github_repo(owner: String, repo: String) -> Result<String, String> {
    if !owner.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
        return Err("invalid owner".into());
    }

    if !repo.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
        return Err("invalid repo".into());
    }

    let url = format!("https://api.github.com/repos/{owner}/{repo}");

    let client = reqwest::Client::new();
    let response = client
        .get(url)
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "my-tauri-app")
        .send()
        .await
        .map_err(|e| e.to_string())?;

    response.text().await.map_err(|e| e.to_string())
}

That’s boring, restrictive, and much safer.

Option 3: Use native HTTP plugins

Depending on your Tauri version and stack, you may use a plugin that exposes native HTTP to the frontend. From the frontend developer’s perspective, this feels like fetch(), but under the hood it may execute natively instead of through the WebView network stack.

Pros

  • Nice developer experience
  • Often avoids browser CORS restrictions
  • Less boilerplate than custom Rust commands
  • Good for apps that need broad API access

Cons

  • Same trust problem as Rust commands
  • Can blur the line between browser-safe and native-powerful operations
  • Easy for teams to forget they removed a browser security control
  • Plugin permissions need careful review

If you use this route, treat it as privileged I/O. Don’t think of it as “frontend networking but easier.” Think of it as native capability exposed to the renderer.

Comparison table

Browser fetch in WebView

Best for: public APIs that already support CORS

Security profile: strongest browser-side isolation

Pain level: high when API CORS is broken

My take: use it by default for simple public API access

Rust backend requests

Best for: authenticated APIs, signed requests, secret handling, unsupported CORS

Security profile: powerful but easier to misuse

Pain level: moderate engineering cost, lower runtime friction

My take: the right choice when you need control, but lock it down hard

Native HTTP plugin

Best for: teams wanting frontend ergonomics with native networking

Security profile: similar risks to Rust-side proxying

Pain level: low initial setup, hidden long-term security cost

My take: convenient, but convenience makes people sloppy

What changes with headers?

A lot of frontend developers only think about Access-Control-Allow-Origin. That’s only part of the story.

For Tauri frontend fetches, these headers still matter:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials
  • Access-Control-Expose-Headers

GitHub is a useful real-world example because it exposes operational headers that apps actually care about:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That means your frontend can inspect rate-limit state and pagination metadata directly. If an API forgets Access-Control-Expose-Headers, you may see the request succeed but still be unable to read the headers you need.

For native-side requests, these CORS response headers mostly stop mattering. Your Rust code can read the response regardless, because it’s not bound by browser CORS rules.

That’s convenient. It also means you can accidentally depend on behavior that won’t work in a normal browser build of the same app.

For Tauri apps, I usually recommend this split:

  • Use WebView fetch() for public, low-risk APIs that already support CORS
  • Use Rust/native requests for sensitive auth flows, API signing, and secret-bearing requests
  • Never expose unrestricted arbitrary URL fetching to the frontend
  • Whitelist hosts, validate paths, and keep the command surface tiny

Also harden the renderer. If your frontend can be XSS’d, every native bridge becomes more dangerous. Tauri permissions, command design, CSP, and isolation matter here. If you’re reviewing broader response-header hardening beyond CORS, https://csp-guide.com is a good companion resource.

The main trade-off

CORS in Tauri is not really about “how do I disable browser errors.” It’s about deciding which side of the app gets network power.

If the WebView makes the request, the browser keeps some guardrails.

If Rust or a native plugin makes the request, you get flexibility and fewer integration problems, but now the app has to enforce its own rules.

That’s the real comparison. CORS becomes less of a protocol problem and more of an architecture decision.