Search...Search plugins and themes...
⌘K
Sign in
  • Get started
  • Download
  • Pricing
  • Enterprise
  • Account
  • Obsidian
  • Overview
  • Sync
  • Publish
  • Canvas
  • Mobile
  • Web Clipper
  • CLI
  • Learn
  • Help
  • Developers
  • Changelog
  • About
  • Roadmap
  • Blog
  • Resources
  • System status
  • License overview
  • Terms of service
  • Privacy policy
  • Security
  • Community
  • Plugins
  • Themes
  • Discord
  • Forum / 中文论坛
  • Merch store
  • Brand guidelines
Follow us
DiscordTwitterBlueskyThreadsMastodonYouTubeGitHub
© 2026 Obsidian

Web Viewer UA Override

Ian MonroeIan Monroe1k downloads

Routes Obsidian's web views through a clean Electron session with a real Chrome user agent, so Google and other embedded-browser-hostile sign-ins work.

Add to Obsidian
  • Overview
  • Scorecard
  • Updates6

An Obsidian plugin that routes web views through a clean Electron session with a real Chrome user agent, so Google sign-in works inside Obsidian.

If you have ever opened Google Docs, Gmail, or anything behind a Google login in Obsidian's Web Viewer and been met with a 401 or a "this browser is not supported" wall, this is why, and this fixes it.


The problem

Obsidian's main process rewrites HTTP headers for the Electron session that web views run in. Here is the relevant handler, deminified from obsidian.asar (1.13.7):

ipcMain.on("create-browser-session", async (evt, partition, adblock) => {
  let entry = sessions[partition];
  if (!entry) {
    entry = { session: session.fromPartition(partition), adblock: !!adblock };
    sessions[partition] = entry;

    // strip "obsidian/1.13.7" and "Electron/43.3.0" out of the UA
    entry.session.setUserAgent(
      entry.session.getUserAgent().split(" ")
        .filter(tok => !/^(obsidian|electron)/i.test(tok)).join(" ")
    );

    // EasyList + EasyPrivacy ad blocking
    entry.session.webRequest.onBeforeRequest({ urls: ["https://*/*", "http://*/*"] },
      (d, cb) => cb({ cancel: entry.adblock && adblockEngine.matches(d.url) }));

    // the header rewrite
    entry.session.webRequest.onBeforeSendHeaders({ urls: ["https://*/*", "http://*/*"] },
      (d, cb) => {
        let { requestHeaders: h } = d;
        for (let k in h) {
          if (k.toLowerCase() === "sec-fetch-dest" || k.toLowerCase() === "sec-ch-ua")
            delete h[k];
          else if (k.toLowerCase() === "user-agent"
                   && d.url.startsWith("https://accounts.google.com/"))
            h[k] = "Chrome";          // <-- this
        }
        cb({ requestHeaders: h });
      });

    // permission sandbox
    const ALLOWED = ["clipboard-read", "clipboard-sanitized-write"];
    entry.session.setPermissionCheckHandler((wc, perm) => ALLOWED.includes(perm));
    entry.session.setPermissionRequestHandler((wc, perm, cb) => cb(ALLOWED.includes(perm)));
    entry.session.setDevicePermissionHandler(() => false);
  }
});

Every request to accounts.google.com goes out with a User-Agent header of exactly Chrome. That is not a user agent string, it is the word "Chrome", and Google rejects it with a 401 "malformed". See forum thread 117394 for the original report.

You cannot fix this by setting a user agent on the webview element. The hook rewrites the header after the element's UA has already been applied. You also cannot register your own onBeforeSendHeaders to replace it, because a few lines later Obsidian does this to the default session:

let noop = () => false;
r.onBeforeRequest = noop; r.onBeforeSendHeaders = noop; r.onHeadersReceived = noop;

It overwrites the registration methods themselves. Plugins are locked out of that pipeline by design.

The fix

Notice the if (!entry) guard. The hooks get installed once per partition, and only when something sends the create-browser-session IPC message. A partition that message never names stays clean forever.

So while this plugin is enabled:

  1. app.getWebviewPartition() returns a new partition — persist:wvua-<appId>-clean instead of persist:vault-<appId>. The wvua- is load-bearing; see below.
  2. ipcRenderer.send swallows create-browser-session for that partition, so the main process never initialises it and the hooks never land.
  3. Every <webview> gets an explicit user agent. Document.prototype.createElement is wrapped per window realm, so the useragent attribute is set the instant the element exists, before Obsidian assigns partition and src and before it attaches to the DOM. Electron requires that ordering.
  4. Every <webview> denies permission requests, standing in for the session-level permission sandbox that a fresh partition does not have.
  5. Every <webview> is allowed to open popup windows, and Obsidian's own window.open() handler is displaced so those popups are real windows instead of another web view. See below.

Both Web Viewer tabs and Canvas web embeds call getWebviewPartition(), so both are covered.

The other half: popup sign-ins

Fixing the header rewrite gets you signed into Google. It does not get you signed into everything that uses Google, and the two failures look nothing alike.

"Continue with Google" on a site like claude.ai does not navigate anywhere. It calls window.open(), sends you through Google in the popup, and waits. The popup's last page is a few lines of script:

window.opener.postMessage(credential, origin);
window.close();

Both lines need a live window.opener, and everything below exists to make sure the popup has one. Three separate things in Obsidian take it away, and all three have to go. Fix two and the symptom does not budge.

1. Electron will not open the window

Electron refuses window.open() from a guest page whose <webview> carries no allowpopups attribute, and Obsidian sets none. Every web view gets one.

On its own this changes nothing, because any window-open handler overrides the attribute and there is always one. It is the floor the next part stands on.

2. Obsidian's handler opens a web view, not a window

Obsidian installs its own setWindowOpenHandler on each guest in the main process. That handler opens the address in another web view: a separate top-level browsing context with no opener, so the sign-in completes, the callback page lands with nothing to talk to, and the page that started the flow waits for a message that never arrives. The giveaway is visual. A sign-in popup with an Obsidian title bar is this.

Replacing that handler is harder than it sounds, because setWindowOpenHandler returns its verdict to the main process synchronously, and @electron/remote invokes renderer callbacks asynchronously. Hand it a function from the renderer and the verdict arrives long after Electron has given up and denied the window. The plugin's permission handling declines to cross that boundary for exactly this reason; this is the same trap one API along.

The way through is to never send a function across at all. Compile the handler in the main process instead:

const remote = require('@electron/remote');

const install = remote.require('vm').runInThisContext(`
  (function (guest, host, channel) {
    guest.setWindowOpenHandler(function () { return { action: "allow" }; });
    // ...plus the popup wiring described below
    return true;
  })
`);

install(remote.webContents.fromId(webview.getWebContentsId()),
        remote.getCurrentWebContents(),
        'wvua-popup-trace');

install is a main-process function. Its arguments are two WebContents and a string: both objects are already main-process objects, so @electron/remote passes them back by reference rather than serialising them, and the handler body closes over nothing in the renderer. It runs entirely in main, synchronously, the way Electron needs it to.

It has to run on did-attach, because the guest has no WebContents before that and Obsidian installs its handler as part of attaching. Ours goes in second and wins.

3. Obsidian will not let the window navigate

With the handler replaced, the popup is a real window, on the right session, opened at the right URL, and it renders nothing at all. The trace explains why by saying almost nothing:

popup requested     {url: 'https://accounts.google.com/o/oauth2/v2/auth?...'}
popup created       {url: 'https://accounts.google.com/o/oauth2/v2/auth?...', sameSession: true}
popup will-navigate {url: 'https://accounts.google.com/o/oauth2/v2/auth?...'}

Then silence. No load, no failure. A navigation the network stops reports a failure; one that is announced and then goes quiet was cancelled, and event.preventDefault() on will-navigate is what that looks like from outside. Obsidian keeps its own windows from navigating away from the app, and it attaches those guards when a WebContents is created, which is before did-create-window fires.

The popup is not an Obsidian window, so the guards come off it:

for (const event of ['will-navigate', 'will-frame-navigate', 'will-redirect']) {
  popup.removeAllListeners(event);
}

Only the popup is touched. The main window and the web views keep theirs. The count found is reported before the strip, so if Obsidian stops guarding and the count comes back 0, the assumption is visible instead of buried. The plugin's own listeners attach afterwards, or they would strip themselves.

That guard is there to stop an Obsidian window being navigated away from the app, which would leave the app somewhere it cannot come back from. A popup showing a sign-in page has no app content to protect and is an ordinary browser window, so letting it navigate is what it is for. It inherits the web view's webPreferences, so it is no more privileged than the tab that opened it.

The second navigation matters as much as the first. The auth URL carries response_mode=form_post, so Google's callback arrives as a form POST back to the site that started the sign-in. That is renderer-initiated too, and it would hit the same guard.

Behind that there is a belt. Two seconds in, a popup that has committed no address and is not fetching anything gets loadURL(), which emits no will-navigate and so goes around any guard that survived the strip. It holds off from three kinds of popup that are behaving: one opened deliberately empty for its opener to navigate, one that has already committed an address, and one that is merely slow.

Popups and the user agent

A popup is a new WebContents, not the guest, so it does not inherit the element's useragent attribute. It takes the session's user agent, and on a partition Obsidian never initialised that is Obsidian's own UA, obsidian/1.13.7 Electron/43.3.0 and all. Google blocks it.

So the plugin also calls session.setUserAgent() on the clean partition through @electron/remote, which every later popup inherits. If @electron/remote cannot be loaded, that is a console warning and a line in the settings tab rather than a silent console.debug, because on the popup path it is the difference between working and not.

What it costs

Every window.open() in a web view becomes a real window, including ordinary target="_blank" links that used to open an Obsidian tab. That is the whole of what the Open popups as real windows setting turns off.

Nothing puts Obsidian's window-open handler back, because setWindowOpenHandler has no getter. It does not need putting back: disabling the plugin rebuilds every web view, and the main process installs its handler again on each new guest.

Why the partition is not called vault-something

Because Obsidian would delete it on every launch, and for a long time it did.

Electron stores a named partition in <userData>/Partitions/<name>, with the persist: prefix stripped. At startup, before any window exists, Obsidian's main process sweeps that directory:

async function cleanPartitions() {
  let dir = path.join(userData, "Partitions");
  for (let name of await fs.promises.readdir(dir)) {
    let id = name.replace(/^vault-/, "");
    vaults[id] || await removePartition(id);
  }
}
async function removePartition(id) {
  console.log("Removing partition for vault " + id);
  return fs.promises.rm(path.join(userData, "Partitions", `vault-${id}`),
                        { recursive: true, force: true });
}

It is a garbage collector for vaults you have deleted: any directory named vault- plus something that is not a registered vault id gets rm -rf'd. vault-<appId>-clean is precisely that shape, so the web view cookie jar was written out correctly during the session and then destroyed before the next launch could read it. Sign-ins never survived a restart, and the reason was never anything to do with flushing.

Measured on Obsidian 1.13.7, across one quit-and-relaunch:

before after
Partitions/vault-<appId>-clean/ created 09:05:27 09:39:14
its Network/Cookies 36 KB, 48 rows, all persistent 20 KB, empty schema
its Local Storage/leveldb/*.log 40381 B 401 B
Partitions/vault-<appId>/Cookies (Obsidian's own) 288 KB 288 KB, mtime unchanged

A name that does not begin with vault- survives, because the strip is then a no-op and the removal targets Partitions/vault-<name> — a path that does not exist, against an rm with force: true. Two probe directories through the same restart confirm it: vault-zzprobe-deleteme was deleted, zzprobe-deleteme was not.

That asymmetry between the name read and the path removed is a bug in Obsidian, and this plugin depends on it. It fails safe. If Obsidian ever fixes the sweep, web views go back to forgetting logins between restarts, which is exactly where they already were — nothing breaks, and nothing outside that directory is affected. Nothing else in Obsidian reads it.

Install

Via BRAT (recommended)

  1. Install BRAT from Community Plugins.
  2. BRAT: Add a beta plugin for testing.
  3. Paste https://github.com/imonroe/obsidian-webview-ua-override.
  4. Enable Web Viewer UA Override in Settings → Community plugins.

Manual

  1. Grab main.js, manifest.json and versions.json from the latest release.
  2. Drop them in <your vault>/.obsidian/plugins/webview-ua-override/.
  3. Reload Obsidian and enable the plugin.

There is no build step. main.js is plain CommonJS, committed as-is, so you can also just clone the repo straight into your plugins folder and read every line before you trust it.

Settings

Setting Default What it does
User agent empty The UA string web views report. Empty means "take Obsidian's own UA and strip the obsidian/ and Electron/ tokens", which is exactly what Obsidian does for its own sessions and yields a normal Chrome UA.
Partition suffix clean Appended to the partition name. Change it to start a brand new cookie jar, which is the fastest way to sign out of everything at once.
Open popups as real windows on Gives window.open() a real window with a live window.opener, instead of the web view Obsidian would open. Required for popup-mode sign-ins. Turning it off sends popups, and every link that opens a new window, back to an Obsidian tab.
Deny permission requests on Denies camera, microphone, geolocation, notifications, MIDI, pointer lock, fullscreen and open-external requests from pages in web views. Leave it on.
Debug logging off Traces every web view to the console: creation, navigation, load failures, and the page's own console output. Popups are traced either way, but only their failures print unless this is on.

Settings apply to web views opened after you close the settings window. Existing ones keep what they were given.

What to expect

A fresh cookie jar, kept between launches. The clean partition starts empty, so every site wants a new login the first time — once, not once per restart. Disabling the plugin puts you back on the original partition with your old cookies intact, untouched.

No ad blocking in web views. Obsidian's EasyList and EasyPrivacy filtering rides on the same handler that breaks Google sign-in. Skipping one skips the other. There is no way to keep just the good half: it is a single IPC handler, all or nothing.

Permission coverage is partial, and deliberately so. The element-level permissionrequest event covers what Electron routes through it (media, geolocation, notifications, midiSysex, pointerLock, fullscreen, openExternal) and this plugin denies all of it. Synchronous permission checks do not reach that event, and a fresh partition has no setPermissionCheckHandler, so Electron decides those with its own defaults.

The obvious idea is to install session handlers over @electron/remote. Do not. setPermissionCheckHandler returns a boolean synchronously to the main process, and a remote proxy stub returns before the renderer has run anything, so it would hard-deny every check including the clipboard permissions Obsidian itself grants. It would fail silently, which is the worst possible shape for this. Worse, Electron emits the webview permissionrequest event from the default permission request handler it installs for guest contents, so calling setPermissionRequestHandler replaces that outright and the element event stops firing. You would be trading the path that works for a proxied one that hangs.

Verifying it took

Open the developer console with Ctrl+Shift+I (Cmd+Opt+I on macOS) and look for:

[webview-ua-override] active.
  partition: persist:wvua-xxxxxxxx-clean
  userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...

The settings tab reports the same two values and two more:

  • Popup window user agent. If this says it is not set, @electron/remote is missing and popup sign-ins will fail even though signing into Google directly works.
  • Popup windows. Whether the handler compiled in the main process, and whether it has been applied to a web view yet. The main process returns a confirmation rather than the plugin assuming its call landed, so this distinguishes "installed" from "called without throwing".

Then sign in somewhere. Direct Google sign-in should just work. For a popup sign-in the quickest check is visual: click the button and look at the window that appears. A bare window with no Obsidian chrome is the working case. An Obsidian title bar means Obsidian still owns window.open(), and the settings tab will say why.

A popup that fails logs a warning whatever your settings, because a blank window says nothing on its own. For the rest, turn on Debug logging and click the button again:

[webview-ua-override] popup requested {url: 'https://accounts.google.com/...'}
[webview-ua-override] popup created {url: '...', sameSession: true, navigationGuards: 2}
[webview-ua-override] popup guards-removed {count: 2}
[webview-ua-override] popup loaded {url: 'https://accounts.google.com/...'}

sameSession: false means the popup landed on a different Electron session and the opener cannot reach it. navigationGuards: 0 means Obsidian no longer guards window navigation and that part of the plugin is now doing nothing. A popup stalled warning means the navigation was blocked by something other than a listener, and the loadURL() fallback fired.

If you ever see create-browser-session passed through unrecognised args in the console, Obsidian changed the IPC call shape and this plugin has stopped protecting the partition. That warning exists so the failure is visible instead of mysterious.

Uninstalling, and how it reverts

Disabling the plugin unwinds all four patches and sends web views back to the original partition. Nothing persists.

Each restore captures the wrapper it installed and compares identity before unwinding. If another plugin wraps the same method after this one and you unload this one first, it backs off with a console warning rather than silently destroying the other plugin's patch. The unwind itself is unconditional and runs inside a finally, so a throw partway through startup cannot strand a patch.

Compatibility

Developed and tested against Obsidian 1.13.7 (Electron 43) on Windows. It should work anywhere Obsidian's desktop app runs. Desktop only, since it touches Electron.

This plugin depends on Obsidian internals that are not part of the public API: App.getWebviewPartition, the create-browser-session IPC channel, the webviewer view type, and the startup sweep of the Partitions directory described above. It checks for getWebviewPartition at load and refuses to patch anything if it is missing, but a future Obsidian release could still change the shape underneath it. Watch the console warning above.

This plugin should eventually become unnecessary. The right fix belongs in Obsidian: send a valid user agent, stop deleting security headers, and expose a supported way for plugins to create webviews with clean sessions. If that ships, disable this and carry on.

Two things here are worth reporting upstream on their own, independently of any of the above:

  • The partition sweep reads directory X and deletes vault-${X.replace(/^vault-/, "")}. For every name it is meant to handle those are the same path, so the bug is invisible in normal use, but the two are not the same expression and a directory that does not start with vault- is never actually removed.
  • There is no supported way for a plugin to hold a persistent web view session, because the sweep treats every partition that is not a live vault's as garbage.

Releasing

Releases are cut from main by the release workflow. Don't create them from the GitHub UI: Obsidian requires the release tag to match manifest.json exactly, so the workflow creates the tag itself.

To cut a release, on a feature branch:

  1. Bump version in manifest.json.
  2. Add the matching entry to versions.json ("<version>": "<minAppVersion>").
  3. Open a pull request and merge it into main.

The workflow then creates the tag, publishes the release, and attaches main.js, manifest.json and versions.json to it. Merging anything that doesn't change the version is a no-op, so ordinary merges never cut a release.

The same checks run on the pull request, so a v-prefixed version or a missing versions.json entry fails before the merge rather than after it. Step 2 is manual because a ruleset on main requires changes to arrive through a pull request, so the workflow can't commit it for you.

License

MIT

Credits

The root cause was diagnosed by Bryan Monge in Obsidian forum thread 117394, including the observation that a custom partition sidesteps the hooks. This plugin is an implementation of that finding.

HealthExcellent
ReviewSatisfactory
About
Route web views through a clean Electron session with a real Chrome user agent to enable Google sign-in inside Obsidian. Replace Obsidian's rewritten user-agent for Google domains to open Gmail, Google Docs and other Google services without unsupported-browser or 401 errors.
IntegrationsInterface
Details
Current version
1.2.0
Last updated
4 weeks ago
Created
2 months ago
Updates
6 releases
Downloads
1k
Compatible with
Obsidian 1.8.0+
Platforms
Desktop only
License
MIT
Report bugRequest featureReport plugin
Author
Ian MonroeIan Monroeimonroe
GitHubimonroe
  1. Community
  2. Plugins
  3. Integrations
  4. Web Viewer UA Override

Related plugins

Agent Client

Chat with Claude Code, Codex, Gemini CLI, and more via the Agent Client Protocol — right from your vault.

BRAT

Easily install a beta version of a plugin for testing.

Self-hosted LiveSync

Sync vaults securely to self-hosted servers or WEBRTC.

LanguageTool Integration

Advanced grammar and spell checking, powered by LanguageTool.

Hider

Hide interface elements such as tooltips, status bar, titlebar, and more.

Meta Bind

Make your notes interactive with inline input fields, metadata displays, and buttons.

Fast Note Sync

Real-time sync of your vaults across server, mobile, and web; shareable with anyone; supports REST and MCP integrations to build your personal AI knowledge base.

Custom Frames

Turn web apps into panes using iframes with custom styling. Also comes with presets for Google Keep, Todoist and more.

Local REST API with MCP

Unlock your automation needs by interacting with your notes over a secure REST API.

Maps

Adds a map layout to bases so you can display notes as an interactive map view.