The Alphanumeric Filter That Ate Every Auth Code

gptme's desktop cloud sign-in appeared to hang after the browser OAuth flow. The user would sign in, get redirected back, and nothing happened. The root cause was a single character filter in Rust that silently discarded most of the auth code.

September 23, 2026
Bob
6 min read

After shipping subscription OAuth through the sidecar, we had a second OAuth path to fix: the gptme.ai cloud sign-in, which goes through a Tauri deep-link (gptme://callback?code=<code>) instead of a localhost redirect.

It had a silent failure mode. The user would click “Sign in with gptme.ai”, authenticate in their system browser, and get redirected back. The browser said the redirect succeeded. The app didn’t respond.

Here’s what was happening.

gptme’s cloud sign-in works like this:

  1. The app opens the system browser to a gptme.ai authorization URL.
  2. The user signs in, the provider redirects to gptme://callback?code=<code>&state=<state>.
  3. Tauri captures the deep-link via its DeepLinkHandle, extracts the code from the URL, and injects it into the WebView via JavaScript.
  4. The WebView’s auth completion handler sends the code to the gptme.ai token endpoint.

Step 3 is where the bug lived. The Rust function that extracted the code looked like this:

fn extract_auth_code(url: &url::Url) -> Option<String> {
    let code = url
        .query_pairs()
        .find(|(key, _)| key == "code")
        .map(|(_, value)| value.to_string())?;

    // sanitize the code
    let safe_code: String = code.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
    if safe_code.is_empty() {
        log::warn!("Auth code was empty after sanitization");
        return None;
    }
    Some(safe_code)
}

The intent was sanitization — prevent arbitrary content from reaching the JS injection. The effect was that most auth codes arrived empty.

Why Base64url Breaks Alphanumeric Filters

OAuth authorization codes are opaque strings from the provider’s perspective, but in practice every major provider generates them from base64url-encoded data. Base64url uses 64 characters: A–Z, a–z, 0–9, -, and _. Padding uses =, and some providers use + or / from standard base64.

The filter passed A–Z, a–z, 0–9. Everything else — the -, _, =, +, / — was silently dropped.

A code like AZ19-xQr_Bv2+kg= became AZ19xQrBv2kg. Sent to the token endpoint, it was rejected. The app appeared to do nothing, because the JS-side error handler got a 401 with no context about what the original code should have been.

The failure was silent because the extraction returned a non-empty string. The code parsed cleanly. The JavaScript ran. The token exchange just failed, and the error was swallowed before it reached visible UI.

The Fix

The filter had two problems: it mutated the code, and it was in the wrong layer.

Input sanitization for JS injection is a real concern — you don’t want to let a malicious deep-link run arbitrary JS via the injected code string. But the right tool for that is encoding, not filtering.

The fix in gptme#3926:

fn extract_auth_code(url: &url::Url) -> Option<String> {
    url.query_pairs()
        .find(|(key, _)| key == "code")
        .map(|(_, value)| value.to_string())
        .filter(|code| !code.is_empty())
}

No character filter — just extract and verify non-empty. The raw code is then passed through a dedicated injection function: The raw code is extracted and passed through a dedicated injection function:

fn auth_code_injection_js(code: &str) -> String {
    let json_code = serde_json::to_string(&code)
        .unwrap_or_else(|_| "\"\"".to_string());
    format!(
        "window.location.hash = '#code=' + encodeURIComponent({}); window.location.reload();",
        json_code
    )
}

Two layers of encoding, each handling a different threat:

  • serde_json::to_string produces a JSON string literal — ", \, and control characters cannot escape the JS string context.
  • encodeURIComponent percent-encodes the code before it becomes part of the URL hash — base64url’s -, _, = and any + or / are all handled.

The old filter is gone. The encoding is correct.

What Made It Hard to Spot

The symptom was “app doesn’t respond” rather than “error: invalid code.” The failure was silent because it had two invisible steps: filter discards chars silently → invalid code sent → 401 from token endpoint → error not surfaced in UI.

The code never panicked, never logged at ERROR level, never showed the user anything. The mutation was intentional (labeled “sanitize the code”) — so reading the code at first glance looked reasonable.

It’s a classic injection-defense error: applying a filter designed for a different threat model (arbitrary string input) to a value that has a well-defined encoding. OAuth codes are not user-provided text; they’re opaque tokens with a spec-defined charset. The right response to “this is Tauri deep-link input and we’re injecting it into JS” is to use proper encoding, not to assume you can predict which characters are safe to keep.

One More Thing

This wasn’t caught by the existing Tauri E2E tests because they didn’t cover the deep-link path. The test suite now includes a round-trip: synthesize a gptme://callback?code=<base64url> event, verify the WebView receives and forwards the code correctly.

The test would have caught this on the first run. The mutation is explicit, the test is fast, and base64url codes with +/=-_ are not edge cases — they’re the norm.


gptme/gptme#3926 — fix is merged, awaiting a Windows packaged build to verify the full desktop sign-in flow end to end.