DEV Community

How We Built a Browser-Based Monero Wallet With Zero KYC

Most Monero wallets require you to either download an app, sync a full node, or hand over your personal data. We wanted to build something different — a Monero wallet that works the moment you open it in your browser. No installs, no KYC, no email. Just a seed phrase, a password, and a working XMR address in under 30 seconds.

Here's how we built WebMonero and the technical decisions behind it.

The Problem

If you want to use Monero today, your options look like this:

  • Monero GUI — full node sync (100+ GB, several hours), desktop only
  • Feather Wallet — lightweight but still desktop only
  • Cake Wallet — great mobile app, but you need to install it
  • MyMonero — has a web version, but the interface is from 2017

There's a gap: no modern, fast, browser-based wallet for Monero that respects user privacy. We decided to fill it.

Architecture Overview

The stack is intentionally simple. Fewer moving parts = fewer things that break.

Backend:

  • Python + Flask
  • SQLite with WAL mode (fast reads, atomic writes)
  • Gunicorn with gthread workers behind Nginx reverse proxy
  • Cloudflare for DDoS protection and CDN

Monero Infrastructure:

  • monerod running in pruned mode on the same VPS
  • monero-wallet-rpc for all wallet operations
  • Each user gets a unique Monero subaddress (not a shared deposit address)

Frontend:

  • Vanilla HTML/CSS/JS — no React, no Vue, no framework
  • SPA-like navigation using fetch + pushState (partial HTML responses)
  • Dark theme, responsive, mobile-first

How User Accounts Work

We don't use email/password authentication. Instead:

  1. User clicks "Create Wallet"
  2. Server generates a cryptographically random 12-word mnemonic seed phrase
  3. User writes it down and verifies by confirming random words
  4. User sets a password
  5. We store SHA-256(seed_phrase) as the user identifier and bcrypt(password) for auth
  6. A new Monero subaddress is created via monero-wallet-rpc and linked to the user

Login = seed phrase + password. No email, no phone, no cookies to track you across sessions.

The seed phrase is never stored — only its SHA-256 hash. If the user loses their seed, we cannot recover it. This is by design.

Subaddress Isolation

Every user gets their own Monero subaddress within a single wallet account. This means:

  • Deposits go directly to the user's unique address
  • We track incoming transfers per subaddress via get_transfers RPC
  • Balance = (total incoming to subaddress) - (total sent from DB) - (deposit fee)

This is the same model custodial exchanges use, but simplified for a wallet service.

Balance Calculation

We don't store balances in the database. They're calculated in real time:

available = confirmed_incoming + internal_receives - total_spent - deposit_fee
Enter fullscreen mode Exit fullscreen mode
  • confirmed_incoming — from Monero RPC (on-chain deposits to user's subaddress)
  • internal_receives — from our DB (transfers from other platform users)
  • total_spent — from our DB (all completed sends + network fees)
  • deposit_fee — 0.5% of total chain deposits

This approach means the balance is always accurate and can't drift out of sync.

Sending XMR

When a user sends Monero, we first check if the recipient is another user on our platform:

Internal transfer (recipient is on WebMonero):

  • Atomic database transaction: debit sender + credit receiver in one SQL transaction
  • Instant, zero fees
  • No blockchain transaction needed

External transfer (recipient is an outside address):

  • Validate address via validate_address RPC
  • Check available balance including fee buffer for network costs
  • Execute transfer via transfer RPC with user-selected priority
  • Record in database after RPC confirms

The amount the user enters is the amount the recipient gets. Network fees are charged on top, not deducted from the send amount. This matches how most exchanges handle it and avoids the "I sent 1 XMR but they received 0.99997" confusion.

Preventing Double-Spend

With multiple Gunicorn workers handling concurrent requests, a race condition could allow a user to submit two send requests simultaneously and overdraw their balance.

Our solution: database-level locking.

Before any send operation, we atomically acquire a lock in SQLite:

UPDATE wallets SET send_locked = 1, send_locked_at = datetime('now')
WHERE user_id = ? AND (send_locked = 0 OR send_locked_at < datetime('now', '-60 seconds'))
Enter fullscreen mode Exit fullscreen mode

If rowcount = 0, another worker is already processing a send for this user. The request gets a 429 response.

The 60-second stale lock timeout handles edge cases where a worker crashes mid-transaction.

Commission Model

0.5% of every incoming chain deposit. That's it.

The fee is calculated dynamically:

deposit_fee = total_chain_incoming * 0.005
Enter fullscreen mode Exit fullscreen mode

Every new deposit proportionally increases the fee. The user sees their balance net of fees at all times. Sending has no platform fee — only the standard Monero network fee.

Performance Optimizations

Initial page loads were slow (~3-4 seconds). We brought it down to under 1 second:

  • SPA navigation — after the initial load, page transitions fetch only <main> content via X-SPA: 1 header, reducing HTML by ~83%
  • Parallel API calls — dashboard loads balance, transactions, and XMR price simultaneously via Promise.all
  • Nginx — gzip, static asset caching (30 days), keepalive connections
  • Cloudflare — edge caching, Brotli compression
  • Async font loading — fonts don't block render
  • instant.page — prefetches links on hover

SEO for a Web App

Since the app is partially SPA, we needed to ensure Google can crawl all content:

  • Server-side rendered HTML for all routes (no client-side-only rendering)
  • sitemap.xml with all public pages including blog posts
  • Schema.org markup: WebApplication, FAQPage, Organization, BlogPosting
  • Open Graph and Twitter Card meta tags
  • Blog with SEO-optimized articles targeting long-tail Monero keywords

What I'd Do Differently

Use PostgreSQL instead of SQLite. SQLite works fine at our current scale, but the single-writer limitation means we need application-level locking for sends. PostgreSQL's SELECT FOR UPDATE would handle this more cleanly.

Add WebSocket for real-time balance updates. Currently the dashboard polls the API. WebSocket would give instant feedback when deposits arrive.

Consider a non-custodial model. Client-side key derivation from the seed phrase would eliminate the need to trust the server. But it would also eliminate internal transfers, make the UX harder, and complicate fee collection. Trade-offs.

Try It

If you want to see it in action: webmonero.com

Create a wallet, send yourself some XMR from another wallet, poke around. It takes 30 seconds.

The project is actively maintained. Feedback, bug reports, and questions are welcome.


Follow the project:

Top comments (0)