Ghost is a modern open-source headless CMS and publishing platform used by thousands of blogs, media organizations, and membership sites. Its dual API architecture creates distinct security considerations: the Admin API key, when used in server-side code, should never appear in client-side JavaScript — if it does, any user who reads the page source can perform full administrative actions including creating/modifying posts, uploading themes, and injecting code into site headers; Ghost themes are Handlebars templates with JavaScript — a malicious theme uploaded by an admin user can execute arbitrary Node.js code on the Ghost server through custom theme helpers; the Content API is designed to be public (its key is embedded in frontends) but returns all content including unpublished drafts and scheduled posts when queried with ?filter=status:draft; Ghost's webhook feature sends POST requests to configured URLs on content events — if webhook URLs are configurable by authenticated users without restriction, this enables SSRF to internal services; and Ghost's member management API exposes subscriber email addresses and membership tier information to admin users. This guide covers systematic Ghost CMS security assessment.
# Ghost Admin panel: /ghost/
# Admin API: /ghost/api/admin/
# Content API: /ghost/api/content/
# Ghost version from meta tag
curl -s "https://example.com" 2>/dev/null | \
grep -oE 'ghost/[0-9]+\.[0-9]+\.[0-9]+' | head -1
# Check if Ghost Admin is accessible (login page = Ghost detected)
curl -s -o /dev/null -w "%{http_code}" \
"https://example.com/ghost/" 2>/dev/null
# Search client-side JavaScript for API keys
# Ghost API keys follow the pattern: hex_id:hex_secret (Admin) or hex string (Content)
# Dangerous pattern: Admin API key in browser-side JavaScript
# Grep page source and linked JavaScript files for Admin API key pattern
curl -s "https://example.com" 2>/dev/null | \
grep -oE '[0-9a-f]{24}:[0-9a-f]{64}' | head -5
# Content API key (shorter, 26 hex chars)
curl -s "https://example.com" 2>/dev/null | \
grep -oE 'key=[0-9a-f]{26}' | head -5
# Test Content API key access (Content API keys are intentionally public)
CONTENT_KEY="your-content-api-key"
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&limit=5" \
2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
print(f'Posts returned: {len(posts)}')
for p in posts:
print(f\" [{p.get('status','?')}] {p.get('title','?')}\")
" 2>/dev/null
# The Content API returns published posts by default
# But it can be queried with status filters to access drafts
CONTENT_KEY="your-content-api-key"
# Test if draft posts are accessible via Content API
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&filter=status:draft&limit=50" \
2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
if posts:
print(f'EXPOSED: {len(posts)} DRAFT posts accessible via Content API')
for p in posts:
print(f\" DRAFT: {p.get('title','?')}\")
else:
err = d.get('errors', [])
if err:
print(f'Drafts protected: {err[0].get(\"message\",\"?\")}')
" 2>/dev/null
# Check for scheduled posts
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&filter=status:scheduled" \
2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
if posts:
print(f'{len(posts)} scheduled posts accessible')
" 2>/dev/null
# With Admin API key: list all members (subscription email addresses)
# Admin API uses JWT authentication
ADMIN_KEY="id:secret" # format: hex_id:hex_secret
ID="${ADMIN_KEY%%:*}"
SECRET="${ADMIN_KEY##*:}"
python3 -c "
import jwt, time, datetime, requests
iat = int(time.time())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': '${ID}'}
payload = {'iat': iat, 'exp': iat + 300, 'aud': '/ghost/api/admin/'}
import hmac, hashlib, base64, json
h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode()
p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
sig_b = hmac.new(bytes.fromhex('${SECRET}'), f'{h}.{p}'.encode(), hashlib.sha256).digest()
sig = base64.urlsafe_b64encode(sig_b).rstrip(b'=').decode()
print(f'{h}.{p}.{sig}')
" 2>/dev/null
| Security Test | Method | Risk |
|---|---|---|
| Admin API key in client-side JavaScript | Grep page source for [0-9a-f]{24}:[0-9a-f]{64} pattern — Admin key enables full content and user management | Critical |
| Draft posts accessible via Content API filter | GET /ghost/api/content/posts/?key={key}&filter=status:draft — exposes unpublished content and scheduled releases | High |
| Member email list accessible with Admin API | GET /ghost/api/admin/members/ with Admin JWT — retrieves all subscriber email addresses and membership tiers | High |
| Ghost Admin panel publicly accessible | GET /ghost/ returns login page — brute force or credential stuffing possible without rate limiting | High |
| Webhook SSRF via admin-configurable URLs | Configure webhook pointing to internal service URL — Ghost POSTs to internal target on content events | High |
| Theme upload for server-side code injection | Upload malicious theme zip via Admin API /themes/ endpoint — custom Handlebars helpers execute Node.js code | Critical |
Ironimo tests Ghost CMS deployments for Admin API keys exposed in client-side JavaScript enabling full content management, draft and scheduled post exposure via the Content API filter parameter, member subscriber email addresses accessible via the Admin API, Ghost admin panel publicly accessible from the internet without IP restriction, webhook configurations pointing to internal service URLs enabling SSRF, code injections added to site headers via Ghost's Code Injection feature, and weak admin account passwords susceptible to brute-force via the session API.
Start free scan