Linkwarden is a widely deployed self-hosted bookmark manager and link archiver — it stores complete bookmark libraries with archived full-page HTML snapshots, PDF exports, and screenshots of bookmarked pages. Key assessment areas: NEXTAUTH_SECRET is the NextAuth.js session signing key enabling account takeover; the API token provides access to complete bookmark libraries including private collections; archived page snapshots stored on the file system reveal complete browsing content; and the PostgreSQL database stores all links, collections, and team membership data. This guide covers systematic Linkwarden security assessment.
# Linkwarden — authentication and API token testing
LW_URL="https://links.example.com"
# Linkwarden uses NextAuth.js credentials provider
# Test common weak admin credentials
for CRED in "admin@example.com:admin" "admin@links.example.com:password" \
"user@example.com:linkwarden" "admin@example.com:changeme"; do
EMAIL=$(echo $CRED | cut -d: -f1)
PASS=$(echo $CRED | cut -d: -f2)
RESULT=$(curl -s -X POST "${LW_URL}/api/v1/auth/callback/credentials" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${EMAIL}\",\"password\":\"${PASS}\"}" 2>/dev/null)
echo "${EMAIL}: $(echo $RESULT | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('url','FAIL'))" 2>/dev/null)"
done
# API token generation — via user profile settings
# Linkwarden generates API tokens in /settings/api
# Test API token authentication
LW_TOKEN="lw_your-api-token"
curl -s "${LW_URL}/api/v1/users/me" \
-H "Authorization: Bearer ${LW_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
user = d.get('response',d)
print(f'Authenticated user:')
print(f' ID: {user.get(\"id\")}')
print(f' Name: {user.get(\"name\")}')
print(f' Email: {user.get(\"email\")}')
print(f' Role: {user.get(\"role\")}')
print(f' IsOwner: {user.get(\"isOwner\")}')
" 2>/dev/null
# Check if registration is open
curl -s -X POST "${LW_URL}/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"name":"Test User","username":"testuser","email":"test@attacker.com","password":"testpassword123"}' \
2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('response'):
print(f'OPEN REGISTRATION: Account created id={d.get(\"response\",{}).get(\"id\")}')
elif 'error' in str(d).lower() or 'disable' in str(d).lower():
print(f'Registration restricted')
else:
print(f'Response: {str(d)[:100]}')
" 2>/dev/null
# Linkwarden API — bookmark and collection enumeration
LW_URL="https://links.example.com"
LW_TOKEN="lw_your-api-token"
# Get all links (bookmarks)
curl -s "${LW_URL}/api/v1/links?take=50&skip=0" \
-H "Authorization: Bearer ${LW_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
links = d.get('response',[]) if isinstance(d,dict) else d
print(f'Links: {len(links)}')
for link in links[:10]:
print(f' [{link.get(\"id\")}] {link.get(\"name\",\"\")}')
print(f' URL: {link.get(\"url\",\"\")[:80]}')
print(f' desc: {str(link.get(\"description\",\"\"))[:60]}')
print(f' collection: {link.get(\"collection\",{}).get(\"name\")}')
print(f' archived: {bool(link.get(\"image\") or link.get(\"pdf\") or link.get(\"readable\"))}')
" 2>/dev/null
# Get all collections including private ones
curl -s "${LW_URL}/api/v1/collections?take=50&skip=0" \
-H "Authorization: Bearer ${LW_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
collections = d.get('response',[]) if isinstance(d,dict) else d
print(f'Collections: {len(collections)}')
for c in collections:
print(f' [{c.get(\"id\")}] {c.get(\"name\")} isPublic={c.get(\"isPublic\")}')
print(f' links={c.get(\"_count\",{}).get(\"links\",0)} owner={c.get(\"owner\",{}).get(\"name\")}')
members = c.get('members',[])
for m in members:
print(f' member: {m.get(\"user\",{}).get(\"name\")} <{m.get(\"user\",{}).get(\"email\")}>')
" 2>/dev/null
# Access archived page content (HTML snapshots, PDFs, screenshots)
LINK_ID="123"
# Archived HTML snapshot
curl -s "${LW_URL}/api/v1/archives/${LINK_ID}" \
-H "Authorization: Bearer ${LW_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
archive = d.get('response',d)
print(f'Archive:')
print(f' image: {archive.get(\"image\")}')
print(f' pdf: {archive.get(\"pdf\")}')
print(f' readable: {archive.get(\"readable\")}')
" 2>/dev/null
# Linkwarden NEXTAUTH_SECRET and database extraction
# Docker environment variables
docker inspect linkwarden 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 ['NEXTAUTH_SECRET','DATABASE','POSTGRES','DISABLE_REGISTRATION',
'GOOGLE','GITHUB','STORAGE']):
print(e)
" 2>/dev/null
# NEXTAUTH_SECRET — signs all NextAuth.js session JWTs
# DATABASE_URL — PostgreSQL connection string
# DISABLE_REGISTRATION — prevents new user sign-ups
# GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET — OAuth credentials
# GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET — OAuth credentials
# STORAGE_FOLDER — where archived pages are stored on disk (/data by default)
# PostgreSQL — all Linkwarden data
DB_URL="postgresql://linkwarden:PASSWORD@localhost/linkwarden"
psql "$DB_URL" 2>/dev/null << 'EOF'
-- All user accounts
SELECT u.id, u.name, u.username,
u.email, u.password,
u.role, u."isOwner",
u."createdAt"
FROM "User" u
ORDER BY u."isOwner" DESC, u."createdAt" ASC
LIMIT 20;
-- All links/bookmarks with URLs
SELECT l.id, l.name,
l.url, -- The bookmarked URL
l.description,
l."collectionId",
c.name as collection_name,
c."isPublic",
u.email as owner_email,
l."createdAt"
FROM "Link" l
JOIN "Collection" c ON l."collectionId" = c.id
JOIN "User" u ON c."ownerId" = u.id
ORDER BY l."createdAt" DESC
LIMIT 30;
-- Archived page file paths
SELECT a.id, a."linkId",
a.image, a.pdf, a.readable,
a."lastPreserved"
FROM "Archive" a
ORDER BY a."lastPreserved" DESC
LIMIT 10;
EOF
-- image, pdf, readable: file paths to archived page snapshots on disk
| Security Test | Method | Risk |
|---|---|---|
| NEXTAUTH_SECRET extraction and JWT session forgery | Read .env NEXTAUTH_SECRET — forge NextAuth.js JWT session tokens for any user; access complete bookmark library including private collections; access all archived page HTML snapshots and PDFs | High |
| PostgreSQL links table bookmark URL enumeration | SELECT url FROM Link — all bookmarked URLs for all users; reveals research topics, visited websites, and interests; PII data indicating what users read and research | High |
| Archive file system content extraction | Read files from STORAGE_FOLDER/archives/ — full HTML snapshots of bookmarked pages including authenticated session content; may include archived banking portals, admin panels, and private corporate wikis | High |
| Open registration account creation | POST /api/v1/auth/register — create accounts if DISABLE_REGISTRATION not set; resource consumption; access to any public collections; use as a launching point for other authenticated API testing | Medium |
| Shared collection member data enumeration | GET /api/v1/collections — all collections with member list including email addresses; enumerates all users who share collections; member emails usable for targeted phishing | Medium |
Ironimo tests Linkwarden deployments for NEXTAUTH_SECRET JWT session token forgery, PostgreSQL links table bookmark URL enumeration, archived page HTML/PDF file system content extraction, open registration endpoint testing, shared collection member email enumeration, Docker NEXTAUTH_SECRET/DATABASE_URL/GOOGLE_CLIENT_SECRET exposure, API token persistence and expiration assessment, private collection access control bypass testing, team member data exposure, and STORAGE_FOLDER permission and access control assessment.
Start free scan