August 27, 2026
Web3 Wallet Development: Connecting Users to dApps
Most wallet engineering effort goes into signing, and most wallet risk arrives earlier — at the moment a page and a wallet agree to talk to…
By John Galt
10 min read
Most wallet engineering effort goes into signing, and most wallet risk arrives earlier — at the moment a page and a wallet agree to talk to each other at all. Connection is usually treated as plumbing: a button, a popup, a green checkmark. It is actually a trust negotiation compressed into two clicks, and what gets negotiated there — which chain, which account, which permissions, for how long — determines what the signing layer will later be asked to approve without a second thought.
A wallet that gets signing right and connection wrong still loses users' funds, because a broad, long-lived, poorly disclosed connection is functionally a standing authorisation, whatever the confirmation screen says at signing time.
Two paths from a click to a session
Two architectures cover almost every dApp connection, and they are not interchangeable.
An injected provider puts a signing interface directly into the page's execution context, so a dApp running in the same browser or in-app browser as the wallet can call it without leaving the page. This is fast and simple, and its trust anchor is strong: the wallet observes the page's actual origin, because the call arrives from that origin's own script context. Its weakness is presentation rather than protocol — a malicious or compromised page can render a convincing fake approval overlay on top of, or instead of, the real prompt, and a user has no independent channel to tell the two apart.
A relay-based session pairs a dApp running on one device with a wallet running on another — typically a desktop site and a mobile signer — through a pairing server and a QR code or deep link. This is what makes "scan to connect" possible, and it is indispensable for mobile-first wallets, but it changes the trust model in two ways. The relay becomes infrastructure the wallet now depends on for message delivery and uptime, and the origin the wallet acts on is asserted by the dApp during pairing rather than observed directly — which is exactly the gap a fake QR code exploits, pairing a victim's wallet to an attacker's session while the victim believes they scanned the real site.
Neither path is strictly safer; they are vulnerable at different points, and a wallet supporting both needs a distinct defence for each rather than one generic "are you sure?" dialog reused across both.
The discovery problem
Before either path can run, the dApp has to find the wallet, and this used to be a genuine mess. Early convention had every extension compete to claim a single global object in the page, so whichever wallet loaded last silently won and every other installed wallet became unreachable — a failure mode invisible to users and maddening for developers testing with more than one wallet installed. The fix that has become standard practice is announcement-based discovery: each wallet advertises its own presence with a name, icon and unique identifier, and the dApp presents a picker rather than guessing. The practical requirement this places on a new wallet is unglamorous but non-negotiable — implement discovery correctly on day one, because a wallet invisible to the picker is invisible to every multi-wallet user, which by now is most of them.
Mobile repeats the problem in a different shape. There is no single global object to announce into; instead, dApps maintain their own registries of which wallets support deep-linking and how, which means a new wallet's mobile connectivity is only as good as its presence in those registries — an integration and relationship cost that is easy to underestimate when the desktop extension already works.
What a session actually grants
Four dimensions, not one
A session is not one permission; it is at least four, and treating them as one is where over-broad access creeps in. Chain scope should name specific networks rather than admitting anything the wallet supports. Account scope should expose the address the user chose to connect, not the whole wallet's address book. Method scope should list which signing operations the dApp may request — a simple message signature is not the same grant as a typed-data signature authorising a token spend, and neither is the same as raw transaction signing. And duration should have a stated boundary, whether that is an explicit expiry or a re-approval requirement, rather than persisting indefinitely by default.
{
"proposer": "app.example.finance",
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1"],
"methods": ["personal_sign", "eth_signTypedData_v4"],
"events": ["accountsChanged", "chainChanged"]
}
},
"accounts": ["eip155:1:0xa17c...9b12"],
"expiry": 1735689600
}{
"proposer": "app.example.finance",
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1"],
"methods": ["personal_sign", "eth_signTypedData_v4"],
"events": ["accountsChanged", "chainChanged"]
}
},
"accounts": ["eip155:1:0xa17c...9b12"],
"expiry": 1735689600
}That structure is worth showing to engineers precisely because it makes the abstract concrete: a session proposal is a small, inspectable object, and a wallet can and should render its actual contents rather than a generic "Connect to app.example.finance?" that is true of every session regardless of scope. The difference between a scoped request — one chain, one account, three methods, a week — and a wildcard request — every chain the wallet supports, the entire address book, every signing method, no expiry — is visible in the object and should be visible on the screen. A wallet that presents both behind an identical button is not collecting consent; it is collecting clicks.
Revocation is part of the grant, not an afterthought
A permission without a visible, working way to withdraw it is not really a permission — it is a decision the user made once and now cannot see or undo. Revocation needs to work from both ends: the wallet's own session list, so a user can end a connection without returning to the dApp that may no longer be trustworthy, and the dApp's interface, for the ordinary case of a user simply being done. Neither should depend on the other's cooperation to take effect, and a wallet's session list is worth treating as a first-class screen rather than a settings submenu — it is the only place a user can see everything they have ever agreed to in one view, which makes it one of the more consequential pieces of interface in the product.
Malicious patterns aimed specifically at connection
Signing-time attacks are covered extensively elsewhere; connection-time attacks are a distinct category and deserve their own defences.
The pattern worth internalising across that table is that connection-time defences are mostly about giving the user an independent, unspoofable channel — a native prompt the page cannot draw over, a persistent session list the current page cannot edit, a visible origin the current session cannot rewrite. Every successful connection-time attack exploits the absence of exactly that independence.
Narrowing the request, not just displaying it
Rendering a wildcard request honestly is the minimum bar. A wallet that also narrows it before the user ever sees a prompt does more work on the user's behalf and removes an entire category of accidental over-approval — the case where a user simply clicks through a broad request because narrowing it themselves is not an option the interface offers.
function narrowProposal(requested: SessionProposal, policy: WalletPolicy): SessionProposal {
return {
...requested,
chains: intersect(requested.chains, policy.supportedChains),
methods: intersect(requested.methods, policy.allowedMethods), // no wildcard "*"
accounts: [policy.activeAccount], // never the whole book
expiry: Math.min(requested.expiry ?? Infinity, policy.maxSessionTtl),
};
}
// If narrowing changed anything material, that is the fact to show —
// not the dApp's original ask.
const narrowed = narrowProposal(proposal, walletPolicy);
const wasNarrowed = !deepEqual(narrowed, proposal);function narrowProposal(requested: SessionProposal, policy: WalletPolicy): SessionProposal {
return {
...requested,
chains: intersect(requested.chains, policy.supportedChains),
methods: intersect(requested.methods, policy.allowedMethods), // no wildcard "*"
accounts: [policy.activeAccount], // never the whole book
expiry: Math.min(requested.expiry ?? Infinity, policy.maxSessionTtl),
};
}
// If narrowing changed anything material, that is the fact to show —
// not the dApp's original ask.
const narrowed = narrowProposal(proposal, walletPolicy);
const wasNarrowed = !deepEqual(narrowed, proposal);The comment in that snippet is the design instruction: when the wallet narrows a request, the confirmation screen should say so, because "this app asked for more than it will get" is exactly the kind of signal that teaches users to notice scope at all. A wallet that narrows silently is safer than one that does not narrow, but a wallet that narrows visibly is the one that improves how its users read every future request.
What the confirmation screen shows, in order
Given a proposal — narrowed or not — the screen has a small number of facts to establish, and the order they appear in is not cosmetic. Origin comes first and largest, because every other fact on the screen is meaningless if the origin is wrong or unverifiable. Scope comes second, rendered as the same four dimensions from the proposal object — chains, account, methods, duration — in plain language rather than as raw identifiers. Anything the wallet narrowed relative to the request comes third, framed as a difference from what was asked rather than buried inside the final scope. Prior history with this origin comes fourth: first connection, or a note that this origin has been connected and disconnected before, which is exactly the kind of context a user cannot reconstruct on their own. And the actions come last — reject and approve, with narrow-and-approve offered as a distinct choice wherever the wallet supports it, rather than forcing an all-or-nothing decision on a request the user only partly wants to grant.
Mobile, desktop and the wallet's own browser
Where the connection actually runs
The same protocol produces different user experiences depending on where the wallet sits relative to the dApp, and each shape has an implementation cost the others do not.
A desktop extension is same-tab and near-instant, which is why it feels the most polished, but it inherits every weakness of the browser's extension model, including the origin-spoofing risk above. A mobile wallet connecting to a desktop dApp round-trips through an app switch — the user leaves the dApp, approves in the wallet, and returns — and that round trip has to survive being interrupted, backgrounded, or resumed hours later with the session state intact; a wallet that loses context on backgrounding will produce support tickets that look like security incidents but are session-management bugs. A wallet's own in-app browser is a third shape entirely, and arguably the one with the most control: because the wallet owns the browser chrome, it can reserve UI space no page script can touch, which is exactly the independent channel the attack table above depends on — an advantage worth designing for deliberately rather than treating the in-app browser as a lesser, temporary feature.
Measuring whether the scoping actually works
A connection layer that looks correct in review can still drift toward over-broad access in production, because the pressure — from dApps wanting fewer prompts and from product teams wanting fewer support tickets — always points toward laxer defaults. A small set of numbers makes that drift visible before it becomes a pattern.
The second and fourth rows are the most diagnostic and the least commonly tracked. A wallet that cannot report what share of sessions it actually narrowed does not know whether its scoping logic is doing anything, and a session list nobody uses to revoke access is a feature that exists on a settings screen and nowhere else — which, for the reasons above, means the permissions it displays are effectively permanent no matter what the underlying protocol allows.
Building the connectivity layer
None of this is exotic engineering, but it is easy to under-scope, because "add WalletConnect support" sounds like an SDK integration and is actually a set of product decisions about disclosure, scoping and revocation that the SDK will not make for you. Teams building this layer for the first time, particularly alongside custody and signing work that already consumes their security attention, often work with a specialist crypto wallet development company specifically to get the connection and session layer right the first time — because a scoping mistake made here is invisible in a demo and only becomes visible once real users have granted real, over-broad, long-lived permissions to real dApps.
The verdict
Connecting a wallet to a dApp looks like the easy half of the product, next to key management and signing, and that appearance is exactly why it accumulates risk. The two connection architectures need separate defences rather than one generic warning dialog. A session is four permissions, not one, and each deserves to be visible and independently revocable. And the attacks that matter at this layer target the absence of an independent channel — a prompt the page cannot fake, a session list the page cannot edit, an origin the session cannot rewrite — rather than any weakness in the underlying cryptography.
A wallet that treats connection with the same seriousness it already gives to signing is recognisable by what it shows before the user clicks: real scope, a real origin, and a real way to leave.
Frequently asked questions
Do we need to support both injected and relay-based connections?
For most consumer wallets, yes, because they cover different situations rather than competing for the same one. Injected support is what makes a browser extension or an in-app browser usable at all, since without it every dApp interaction would require leaving the page. Relay-based support is what lets a mobile-only wallet work with the much larger population of dApps built as desktop-first websites, and it is often the harder integration to skip well, because so much existing dApp tooling assumes it is available. A wallet that ships only one path has quietly decided which class of dApps its users cannot use, and that decision is worth making deliberately rather than by omission.
How much should a dApp be able to request at connection time?
As little as the interaction in front of the user actually requires, with anything broader deferred to a separate, clearly labelled request when it is actually needed. A dApp that only needs to display a balance should not receive transaction-signing rights at connection; a dApp that will eventually need token approvals should ask for those at the point of use, not bundle them into the initial connect. The wallet's role is to make the gap between what was requested and what would be reasonable visible to the user rather than to silently grant whatever is asked, since most users will not read a permissions list closely enough to catch an over-broad request themselves.
What should happen to a session when the user closes the browser or restarts the app?
That is a product decision with real security weight, not a technical default to accept from a library. Persisting sessions across restarts is convenient and is also standing risk: a session that survives indefinitely is a permission a user granted once and may have forgotten entirely. A workable middle ground is to persist the session but attach a shorter re-authentication window for anything beyond low-risk read access, so that balance display can survive a restart while a transaction signature still requires a fresh, visible confirmation. Whatever the policy, it should be stated in the session list rather than left for the user to infer from behaviour.
How should a wallet handle a dApp that requests an unsupported chain?
Clearly and without guessing. Silently switching to a chain the user did not select, or approximating an unsupported network with a similar one, produces exactly the kind of confusion that leads to funds sent on the wrong network. The correct response is to decline the specific chain request, state plainly which networks the wallet does support, and let the user decide whether to proceed on a supported chain or abandon the connection — never to substitute a chain the dApp did not ask for.
Are hardware wallets connected through a browser extension exposed to the same connection risks?
Largely yes, because the connection layer sits above the hardware boundary rather than inside it. A hardware device protects the key material and requires physical confirmation for signing, but the browser extension or companion app that negotiates the session, renders the origin and displays the requested scope is ordinary software subject to the same overlay, discovery and scope risks as any other wallet front end. The hardware element narrows what a successful attack can extract at the signing step; it does not narrow what a user can be tricked into approving at the connection step, which is why the defences in this article apply to hardware-backed wallets as directly as to software ones.