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:
- Browser-side fetch from the WebView
- Rust backend making the request
- 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-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-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.
My recommended default
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.