A private, client-side dashboard for Up Bank accounts and transactions — the desktop/web companion to Up's phone-only app.
Not affiliated with, endorsed by, or officially connected to Up Banking Pty Ltd or Bendigo and Adelaide Bank in any way. This is an unofficial, community-built client that talks to Up's public API using your own Personal Access Token. Use it at your own risk — see Security & encryption and License below.
- UpSized
UpSized reads your Up Bank accounts and transactions using a Personal Access Token you generate yourself, and shows them in a fast, desktop-friendly dashboard:
- Accounts overview — every account as a card, with a Saver/Spending/Home Loan badge, plus an automatic Individual / 2Up split if you have a joint account, and a Home Loan section that only appears if you actually have one.
- Transaction list — infinite-scroll history, monthly separators, live "Pending" badges that clear automatically once Up settles them.
- Transaction detail — click any transaction for a full breakdown: raw merchant text, card method & last 4 digits, who made the purchase, category, round-up, cashback, foreign amount, hold amount, and both timestamps.
- Search — fuzzy filter across description, raw text, message, note, merchant, account, category, and tags.
- Live sync — polls for new transactions on an interval you choose (or manual-only), shows Up's live rate-limit budget, and re-checks anything still "pending" so it never gets stuck.
- Installable — the hosted build is a real PWA (installable, works offline for the app shell) via a service worker.
Everything happens in your browser, talking directly to Up's API. There is no UpSized server. See Security & encryption for the full story.
Currently this can only read your data not write as the Up api does not allow this.
See the Up API for details.
Onboarding |
Connect |
Set password |
All accounts |
Saver account |
Transaction detail |
Settings |
Mobile |
Click any thumbnail for the full-size image.
The app ships as source (index.html + css/ + js/) but can also be packed into a single self-contained HTML file via pack.py. Both are the exact same app — the difference is how you run it, and that difference has one real consequence: whether passkeys are available.
Hosted (served over HTTPS, or http://localhost) |
Standalone (single file, opened via file://) |
|
|---|---|---|
| What it is | index.html + css/js files, served by a real web server |
One file: standalone/UpSized.single.html, generated by pack.py |
| Passkeys (WebAuthn) | ✅ Full support | ❌ Unavailable — file:// is not a "secure context," so the browser refuses WebAuthn outright. The app detects this automatically and drops straight to the password option — nothing breaks, it just silently offers the fallback. |
| Password unlock | Available as an opt-in alternative | The only option |
| Installable as an app (PWA) | ✅ Yes, via the service worker (sw.js) |
❌ No — service workers also require a real origin, can't run from file:// |
| Offline behaviour | Shell is cached after first load and kept fresh automatically | N/A — there's nothing to fetch, it's already one file |
| Best for | Daily use, installing on your phone/desktop, strongest security mode | Trying it instantly, air-gapped use, handing to someone with zero setup |
The distinction that actually matters is the origin (
file://vs.https:///localhost), not which build you picked. If you take the standalone file and upload it to any HTTPS host instead of double-clicking it locally, passkeys work fine there too —pack.pyjust produces a portable artifact, it doesn't lock you out of anything.
You'll need an Up Bank account. Generate a token at api.up.com.au — the app asks for this once, on first run, and encrypts it before it's stored (see below).
Hosted (recommended — unlocks passkeys and PWA install):
# any static file server works; this is just the quickest for local testing
python -m http.server 8080
# then open http://localhost:8080For real use, put the repo's contents on any HTTPS static host (GitHub Pages, Netlify, your own server with the included .htaccess, etc.) — no build step, no backend, no database required.
Standalone (fastest — zero setup, but no passkeys):
python pack.py
# writes standalone/UpSized.single.html — just open it in a browserRe-run pack.py any time you change the source files; it always regenerates the whole bundle from scratch.
- Any modern browser (WebAuthn + IndexedDB support — that's essentially all of them, including mobile)
- Python 3 (standard library only, no
pip installneeded) — only if you want to usepack.pyorsync_manifest.py. The app itself needs nothing but a browser.
This section is meant to be read, not skimmed — being upfront about exactly what protects your data (and what doesn't) matters more here than almost anywhere else in the app.
- No server, ever. UpSized is static files. Your browser talks directly to
api.up.com.auover HTTPS. There is nothing in between — no analytics, no proxy, no logging, nothing operated by anyone but you and Up. - Everything sensitive is encrypted at rest in your browser's local storage (IndexedDB) with AES-256-GCM before it's ever written to disk: your Personal Access Token, every account, every transaction.
- The encryption key never touches disk in raw form. It's held in memory only while the app is unlocked, and is gone the moment you lock it, close the tab, or reload.
flowchart LR
You["🧑 You, in your browser"] -->|"HTTPS, your token"| API["Up's official API\napi.up.com.au"]
You -->|"AES-256-GCM encrypted"| Local["This device only\n(IndexedDB)"]
API -.-> Nothing["🚫 no UpSized server\n🚫 no analytics\n🚫 no third party, anywhere"]
You unlock the app with either a passkey or a password — your choice, made once during setup. Whichever you pick determines how the AES key is derived:
flowchart TD
Start["Set up a lock"] --> Secure{"Secure context available?\n(https:// or localhost)"}
Secure -- "No — e.g. file://" --> Password["Password mode\nPBKDF2-HMAC-SHA256, 600,000 rounds"]
Secure -- "Yes" --> PRFCheck{"Does the authenticator\nsupport the WebAuthn\nPRF extension?"}
PRFCheck -- "Yes" --> PRF["prf mode\nAES key derived directly\nfrom the passkey itself"]
PRFCheck -- "No" --> Fallback["fallback mode\nReal random AES key,\ngated behind the passkey"]
| Mode | How the key is produced | What's stored locally |
|---|---|---|
prf (passkey, best case) |
The WebAuthn PRF extension derives a stable secret straight from the passkey itself on every unlock. The key is never generated or stored anywhere — it's recomputed fresh each time from the passkey + a salt. | Just the credential ID and the salt (a salt isn't a secret by design — it only needs to stay consistent, not hidden). |
fallback (passkey, older authenticators) |
Some authenticators/browsers support passkeys but not the PRF extension. In that case a real random AES-256 key is generated once and stored — access to it is gated behind a successful passkey check, but the key itself isn't mathematically derived from the passkey. | The credential ID and the (encrypted-at-rest-by-the-OS, not by this app) random key material, plus the mode flag. |
password |
A password you choose runs through PBKDF2-HMAC-SHA256 at 600,000 iterations (OWASP's current baseline) to derive the AES key. | A random salt and a small encrypted "check" blob used only to confirm you typed the right password — the password itself is never stored, anywhere, in any form. |
No recovery path for password mode, on purpose. If you forget it, there's no reset — the only way back in is wiping local data and reconnecting from scratch. The app tells you this explicitly before you set a password, and it's a deliberate trade-off: there's no server to hold a "forgot password" flow for.
Passkeys synced by your password manager (iCloud Keychain, 1Password, Google Password Manager, etc.) can be reused on a new device via "I already have a passkey" on the welcome screen. This authenticates against any passkey registered for the site rather than one specific stored credential ID, so a synced passkey is recognised even on a device that's never seen it before. Each device still derives (or generates) its own local key independently — nothing about your data syncs between devices, only the passkey's ability to unlock a device does. Up's data is re-pulled fresh from the API on first load anyway, so there's no cross-device data-consistency problem to solve here.
| Storage | Contents | Encrypted? |
|---|---|---|
IndexedDB — secure store |
Your Up Personal Access Token | ✅ AES-256-GCM |
IndexedDB — accounts / transactions stores |
Every account and transaction record | ✅ AES-256-GCM (per record). createdAt, accountId, and status are kept as plain, unencrypted index fields — this is what makes infinite scroll, "last 100 on startup," and auto-purge possible without decrypting the whole database on every scroll. Nothing sensitive lives in those three fields; everything else (merchant, amounts, message, category, card details, etc.) is inside the encrypted blob. |
localStorage |
Passkey credential ID, PRF/fallback salts, the chosen unlock mode, a small non-secret password "check" blob | ❌ Not encrypted, but nothing here is a secret by itself — a credential ID and a salt are meaningless without the actual passkey/password to go with them. |
| Cache Storage (service worker) | The app's own HTML/CSS/JS shell, for offline load | N/A — it's just the app's own code, not your data. Up's API is explicitly never cached (see sw.js) — every request always hits the network live. |
Up doesn't publish an exact request budget, only that X-RateLimit-Remaining is present on every response — so the app treats it as something to discover live: it reads that header on every call, shows it in the topbar, and automatically backs off with retry/backoff on 429/5xx responses.
Being transparent cuts both ways — here's what's genuinely out of scope for client-side encryption, so you can judge the actual threat model:
- A compromised or unlocked device. If someone has access to your unlocked browser/device, they have access to whatever you have access to — same as any app.
- A malicious browser extension with broad page permissions could, in principle, read data while the vault is unlocked and the key is in memory.
- Whoever hosts the file controls what code runs. If you don't control the hosting yourself (or don't trust it), you're trusting that host with everything — this is true of any client-side web app, not specific to UpSized. Prefer hosting it yourself, or the standalone build, if that matters to you.
- Physical/forensic access to
localStorage+ a live passkey/password is, definitionally, enough to unlock it — that's the whole point of it being your unlock method. - There is no cloud backup, no account recovery, no "forgot password." Losing your passkey and forgetting your password (if you set one instead) means wiping local data and reconnecting from scratch. This is by design, not an oversight — there's no server to hold a recovery flow.
Accessible via the ⚙ icon once unlocked:
- Auto-refresh interval — off (manual only), or every 30s / 1min / 5min / 15min. Each check is a small delta request for "anything newer than what I already have," not a full re-pull.
- Local history retention — how many days of transactions to keep on this device (default 90). Older ones are purged automatically if auto-purge is on.
- Forget this device — wipes all local data (accounts, transactions, token, and the passkey/password enrollment itself) and returns to first-run. Irreversible, and the app makes you confirm it.
UpSized/
├── index.html Markup for all screens (onboarding, lock, main app, modals)
├── manifest.json PWA manifest (readable source — see sync_manifest.py)
├── sw.js Service worker: app-shell caching, network-first strategy
├── css/
│ └── styles.css All styling, one file, CSS custom properties for theming
├── js/
│ ├── crypto.js Vault key derivation & AES-GCM encrypt/decrypt (see Security above)
│ ├── db.js IndexedDB wrapper — accounts, transactions, settings, token
│ ├── api.js Up API client — requests, rate-limit tracking, retry/backoff
│ ├── ui.js Small shared DOM/formatting helpers
│ └── app.js Orchestration: screens, sync engine, rendering, event wiring
├── icons/
│ └── icon.svg App icon (also used as favicon and PWA icon)
├── pack.py Bundles everything into standalone/UpSized.single.html
├── sync_manifest.py Regenerates the inlined manifest <link> in index.html from manifest.json
└── standalone/
└── UpSized.single.html Generated — see "Standalone" above
- Zero dependencies, zero build step. Vanilla HTML/CSS/JS. Nothing to
npm install. - Classic scripts, not ES modules — on purpose.
import/exporttriggers a CORS-checked fetch that Chrome refuses outright underfile://, even for same-folder files. Classic<script>tags don't have that restriction, so every file attaches its API to a sharedwindow.UVnamespace instead. Load order inindex.htmlmatters:crypto.js,db.js,api.js,ui.js, thenapp.js. - CORS-friendly by design. Up's API sends CORS headers that permit direct browser calls, which is the whole reason a server-side proxy isn't needed here.
- Full inline documentation lives at the top of each
js/*.jsfile if you want the deeper "why," not just the "what." - Code is mostly organic (written by me) with a dash of Clause however nearly all documentation was written by Claude because I am lazy and terrible at documenting anyway. (better than no documentation?)
MIT — permissive, free to use/fork/modify (including commercially), with an explicit "as is, no warranty" disclaimer, which matters given this handles real financial data.
See LICENSE.







