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:
-
monerodrunning in pruned mode on the same VPS -
monero-wallet-rpcfor 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:
- User clicks "Create Wallet"
- Server generates a cryptographically random 12-word mnemonic seed phrase
- User writes it down and verifies by confirming random words
- User sets a password
- We store
SHA-256(seed_phrase)as the user identifier andbcrypt(password)for auth - A new Monero subaddress is created via
monero-wallet-rpcand 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_transfersRPC - 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
-
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_addressRPC - Check available balance including fee buffer for network costs
- Execute transfer via
transferRPC 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'))
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
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 viaX-SPA: 1header, 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.xmlwith 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)