logo

Developing a Browser Extension Wallet (Manifest V3): Architecture, Security, and the Parts That Break First

A computer with a glowing light on its surface, indicating it is powered on and in use.

A browser extension wallet feels like the most “native” way to meet users where web3 actually happens: in tabs, in dapps, at the moment a signature is needed.

Then Manifest V3 enters the picture and changes the shape of the work. Your “background page” becomes a service worker that can be suspended. Anything DOM-like moves elsewhere. Message passing becomes the backbone, and state management stops being an implementation detail.

The good news is that MV3 pushes extension wallets toward cleaner boundaries. The hard part is that wallets are stateful by nature, and MV3’s runtime is intentionally not. A solid design is one that still behaves predictably when the browser pauses your worker, a tab reloads, or the user approves a request after switching contexts.

What makes an extension “a wallet” in the first place

Wallets aren’t defined by a UI popup. They’re defined by a set of capabilities that the rest of the web can rely on.

An injected provider that dapps can talk to

Most extension wallets expose a provider object on the page, commonly window.ethereum, following the EIP-1193 provider interface. EIP-1193 intentionally keeps the surface minimal: one request method plus a small set of events.

In practice, this means your extension needs a content script layer that can communicate between the web page and your wallet logic, then return results in a way that matches what dapps expect from popular wallets.

A signing engine that stays private

The extension must be able to sign messages and transactions without exposing private keys to the page context. That sounds obvious, but MV3 makes the “where does the secret live” question more important because service workers can be suspended and restarted.

A practical wallet treats keys as vault data, keeps them encrypted at rest, and keeps decrypted material in memory only while unlocked.

A state model that survives refreshes and browser restarts

A wallet is a long-lived relationship: accounts, connected sites, permissions, and pending requests all need persistence. MV3 gives you extension storage APIs for persistence, but it also encourages you to design state so it can be reconstructed safely after the worker wakes up again.

How Manifest V3 reshapes wallet architecture

MV3 is the main reason extension wallets look “service-oriented” internally, even when the UI is small.

Service workers replace persistent background pages

In MV3, the background context is a service worker. Chrome will terminate the extension service worker after short idle periods and under other conditions, which means you cannot rely on a permanently running background process.

For a wallet, this changes two things right away. First, every request path must tolerate the worker being cold-started. Second, anything that depends on long-lived timers or in-memory caches must have a persistence or recovery story.

DOM work moves out of the service worker

Service workers do not have DOM access. If you need DOM APIs, Chrome’s MV3 approach is to use offscreen documents: hidden extension pages that can run DOM code without showing UI.

This matters for wallets more than people expect. Some clipboard flows, certain crypto UI libraries, and any “hidden window” style logic need a DOM context. Offscreen documents also come with constraints, including limited API access compared to other extension contexts.

Messaging becomes your internal bus

MV3 wallet architecture is essentially a message-driven system. Content scripts, the popup UI, notification windows, and the service worker all communicate using message passing primitives. Chrome’s messaging docs center this pattern around ports and events that fire when another part of the extension connects.

If your internal contract is clean, MV3’s event-driven model becomes an advantage rather than friction.

A clean component map for an MV3 wallet

Most production wallets converge on a similar split, because it maps to security boundaries and MV3 lifecycle constraints.

Content scripts for bridging the page and the extension

Chrome content scripts run in an isolated world, which means the page can’t read your content script variables directly. That isolation is the default protection that keeps dapps from poking at wallet internals, and it’s also why an extension wallet can safely expose a provider without exposing its privileged APIs. 

When you need to inject an EIP-1193 provider into the page, you typically use a two-step bridge: a script that lives in the page context to attach window.ethereum, and an isolated content script that passes requests to the extension via messaging. The important detail is how you shape messages. Requests should be validated, typed, and tagged with an origin so permissions can be applied consistently. The page should receive a provider interface and responses, not the ability to call internal extension functions or inspect vault state.

The service worker as a router and policy engine

Your service worker should behave like a coordinator. It receives provider requests, checks permissions, routes signing requests to the approval UI, and returns results. Since the worker can be terminated after inactivity, this layer should avoid relying on in-memory state as the source of truth. If a request is mid-flight and the worker restarts, you want a path to restore context rather than leaving the dapp hanging or forcing the user to retry.

Think of the worker as the place where rules live and stay centralized. Site connection approvals, allowed methods, chain switching behavior, account selection, and request timeouts all belong here because they should be consistent regardless of which UI surface is open. This is also where you implement “defensive defaults,” such as returning a clean error when the wallet is locked, rejecting unexpected methods, and enforcing one request lifecycle contract so retries don’t create duplicates.

UI surfaces for approval, settings, and onboarding

Most wallets use a popup for quick actions and a full-page extension view for onboarding and advanced settings. Approval prompts are usually separate windows so they remain visible even when the popup closes, and so the user can review a signature request without racing the browser’s popup lifecycle. This division also helps with clarity: a popup is for the wallet’s current state, while approvals are for an immediate decision the user has to make.

The crucial design choice is that UI surfaces are clients of the wallet engine. They shouldn’t contain signing logic or permission decisions, because that creates inconsistent behavior when multiple windows exist or when the worker restarts. Instead, the UI should request data from the service worker, render it in a way the user can understand, and send back an explicit approve or reject action with the request ID. That keeps the signing and policy logic testable and avoids a long tail of bugs caused by duplicated “mini-wallet engines” in UI code.

Offscreen documents for DOM-only needs

When you have tasks that require DOM APIs while the service worker is active, offscreen documents are the MV3 tool for it. They let you run a hidden document with a DOM environment while keeping the service worker as the primary background context. This can matter for certain UX flows and libraries that assume a windowed environment, and it gives you a place to run DOM-dependent operations without turning the whole wallet into a UI-driven system.

Wallets should use offscreen documents sparingly because they add another execution context that must be monitored and secured. Every additional context introduces extra message paths, additional state edges, and more opportunities for lifecycle mismatch if one context is alive while another is suspended. If you do use an offscreen document, treat it like a controlled helper: give it a narrow set of responsibilities, keep data passing minimal, and make sure it can be recreated safely without becoming a hidden “second brain” of the wallet.

Key storage and security decisions that define wallet quality

Security in an extension wallet is mostly about reducing the number of places where secrets can exist.

Encrypt secrets at rest and treat “unlock” as a temporary state

A widely repeated best practice in the extension ecosystem is to store sensitive data encrypted when written to disk, and keep unencrypted secrets in memory only while the vault is unlocked.

For MV3 wallets, this pairs naturally with the service worker lifecycle. If the worker is suspended, the safe default is to return to a locked state unless the user explicitly opted into a session policy. It’s better to annoy a user with an extra unlock than to accidentally keep decrypted keys in a context you don’t fully control.

Be deliberate about what dapps can ask for

EIP-1193 is minimal, which is a gift: you can implement a complete provider while keeping a strict policy layer.

Treat permissions as part of the product. A connected site should not automatically get every method. The wallet should mediate signature requests with clear prompts and stable rules around chain IDs and account selection.

Don’t rely on network interception for core wallet behavior

MV3 changes how extensions can modify network requests, pushing many use cases to declarativeNetRequest. Extension wallets usually shouldn’t depend on intercepting page traffic to function. It complicates compatibility and expands your security surface.

A wallet that works as a provider and signer can keep its core reliable without needing to rewrite or block web requests.

Making the user experience feel stable under MV3 constraints

The biggest UX failures in extension wallets come from lifecycle surprises, not from missing features.

Handle the service worker waking up as a normal event

Chrome explicitly documents that service workers are terminated after inactivity and other conditions. Your wallet UX should assume it can happen at any time. That means request handling should tolerate a cold start, and the approval flow should still resolve even if the worker had to restart mid-process.

A practical pattern is to persist pending requests with IDs, so the worker can restore them and the approval window can reattach without losing context.

Make connection state visible and recoverable

Connected sites, selected account, and selected network should be visible in your UI and easy to correct. When something fails, the user needs a way to see “what the wallet thinks is connected” without digging through settings.

It’s also worth treating timeouts as UX, not just engineering. If a dapp request expires, the wallet should say so clearly and give the user a one-click retry path.

Cross-browser reality check

MV3 is not identical across browsers. Firefox’s approach differs, and support for MV3 features like service workers has historically been uneven, with Firefox emphasizing event-driven backgrounds and a different compatibility story.

If cross-browser support is a requirement, design your core wallet engine so it can run behind different “host” layers. The provider contract, vault, and approval logic can remain the same, while the surrounding extension scaffolding adapts to the browser’s MV3 implementation.

Conclusion

A Manifest V3 extension wallet is a small distributed system hiding inside a browser. The page talks to an injected provider that follows EIP-1193 expectations. Content scripts bridge the isolated world boundary. A service worker coordinates policy and routing, even though it can be suspended after inactivity. Offscreen documents exist for DOM-only needs that service workers cannot handle.

When these pieces have clean contracts, especially around storage, permissions, and request lifecycles, the wallet stops feeling fragile. It becomes predictable in the only environment that matters: the messy one where users switch tabs, wallets, networks, and devices mid-flow.