Security Issues
Security Issues
Section titled “Security Issues”Solutions for FraiseQL security and authentication problems.
Authentication Issues
Section titled “Authentication Issues”Invalid Token
Section titled “Invalid Token”Problem: Error: Invalid token
Causes:
- Token expired
- Token signed with different secret
- Malformed token
- Token from different issuer
Solutions:
# 1. Decode token to check (without verification)python3 -c "import jwtimport jsontoken = 'your-token'decoded = jwt.decode(token, options={'verify_signature': False})print(json.dumps(decoded, indent=2))"
# 2. Check token expiration# If 'exp' field is in past, token is expiredpython3 -c "from datetime import datetimeimport jwtdecoded = jwt.decode(token, options={'verify_signature': False})exp = decoded.get('exp')if exp: print('Expires:', datetime.fromtimestamp(exp)) print('Expired:', datetime.now().timestamp() > exp)"
# 3. Verify JWT_SECRET matchesecho $JWT_SECRET
# 4. Check if secret changed recently# Tokens signed with old secret won't work with new secret# Solution: Re-issue tokens
# 5. Verify token format# Should be: eyJhbGciOiJIUzI1NiI...# Three parts separated by dots: header.payload.signatureMissing Authorization Header
Section titled “Missing Authorization Header”Problem: Error: Missing Authorization header
Cause: Client not sending authentication
Solutions:
# 1. Check client is sending header# JavaScript:fetch(url, { headers: { 'Authorization': `Bearer ${token}` }})
# 2. Check header format# Should be: Bearer <token># Not: Token <token># Not: <token> (no prefix)
# 3. Test with curlcurl -H "Authorization: Bearer ${TOKEN}" http://localhost:8000/graphql
# 4. Check if endpoint requires auth# Some endpoints might be public (@public decorator)@fraiseql.querydef public_posts() -> list[Post]: pass
# 5. Check auth middleware is enabled# FraiseQL should automatically extract token from header# Verify in logs: LOG_LEVEL=debugToken Expired
Section titled “Token Expired”Problem: Error: Token has expired
Solutions:
# 1. Check token expirationdecoded = jwt.decode(token, options={'verify_signature': False})exp = decoded.get('exp') # Timestamp when token expires
# 2. Re-issue token# Token valid for 1 hour (typical)# After 1 hour, must authenticate again
# 3. Implement token refresh@fraiseql.mutationdef refresh_token(refresh_token: str) -> TokenResponse: # Validate refresh token # Issue new access token return TokenResponse( access_token=new_token, expires_in=3600 )
# 4. Client auto-refresh// When access token expires:const new_token = await refresh_token(refresh_token)// Store and use new token
# 5. Increase token expiry (not recommended)# Short-lived tokens (1 hour) are more secure# If needed, use sliding window:# Token auto-refreshes if used within last 5 minutesCORS Preflight Failed
Section titled “CORS Preflight Failed”Problem: Error: CORS policy: Response to preflight request
Cause: CORS configuration incorrect
Solutions:
# 1. Check CORS_ORIGINSecho $CORS_ORIGINS# Should include your frontend origin
# 2. Add frontend origin if missingCORS_ORIGINS=https://app.example.com,https://localhost:3000
# 3. Test preflight requestcurl -X OPTIONS http://localhost:8000/graphql \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST"
# 4. Verify response includes correct headers# Should have:# Access-Control-Allow-Origin: https://app.example.com# Access-Control-Allow-Methods: GET, POST, OPTIONS
# 5. For development with multiple originsCORS_ORIGINS=* # Allow all (development only!)
# 6. For productionCORS_ORIGINS=https://app.example.com,https://www.example.comAuthorization Issues
Section titled “Authorization Issues”Permission Denied
Section titled “Permission Denied”Problem: Error: User does not have permission
Cause: User role lacks required permission
Solutions:
# 1. Check user's current roleSELECT role FROM users WHERE id = 123;
# 2. Check what permissions role hasSELECT rp.permissionFROM user_roles urJOIN role_permissions rp ON ur.role_id = rp.role_idWHERE ur.user_id = 123;
# 3. Add missing permissionINSERT INTO role_permissions (role_id, permission_id)VALUES ( (SELECT role_id FROM user_roles WHERE user_id = 123), (SELECT id FROM permissions WHERE name = 'read:posts'));# 1. Check user's current roleSELECT role FROM users WHERE id = 123;
# 2. Check what permissions role hasSELECT rp.permissionFROM user_roles urINNER JOIN role_permissions rp ON ur.role_id = rp.role_idWHERE ur.user_id = 123;
# 3. Add missing permissionINSERT INTO role_permissions (role_id, permission_id)SELECT ur.role_id, p.idFROM user_roles ur, permissions pWHERE ur.user_id = 123 AND p.name = 'read:posts';# 1. Check user's current roleSELECT role FROM users WHERE id = 123;
# 2. Check what permissions role hasSELECT rp.permissionFROM user_roles urJOIN role_permissions rp ON ur.role_id = rp.role_idWHERE ur.user_id = 123;
# 3. Add missing permissionINSERT INTO role_permissions (role_id, permission_id)SELECT ur.role_id, p.idFROM user_roles ur, permissions pWHERE ur.user_id = 123 AND p.name = 'read:posts';# 1. Check user's current roleSELECT role FROM users WHERE id = 123;
# 2. Check what permissions role hasSELECT rp.permissionFROM user_roles urINNER JOIN role_permissions rp ON ur.role_id = rp.role_idWHERE ur.user_id = 123;
# 3. Add missing permissionINSERT INTO role_permissions (role_id, permission_id)SELECT ur.role_id, p.idFROM user_roles ur, permissions pWHERE ur.user_id = 123 AND p.name = 'read:posts';# 4. Check query requires correct scope@fraiseql.query(requires_scope="read:posts")def posts() -> list[Post]: pass
# 5. Verify user token includes required scopedecoded = jwt.decode(token, options={'verify_signature': False})scopes = decoded.get('scopes', [])# Should include required scopeInsufficient Scope
Section titled “Insufficient Scope”Problem: Error: Insufficient scope for this operation
Cause: Token doesn’t have required permission
Solutions:
# 1. Check token scopespython3 -c "import jwttoken = 'your-token'decoded = jwt.decode(token, options={'verify_signature': False})print('Scopes:', decoded.get('scopes'))"
# 2. Check what scope is required@fraiseql.mutation(requires_scope="write:posts")def create_post(...) -> Post: pass
# 3. Get new token with correct scopes# Authenticate with required scopesPOST /auth/login{ "username": "user", "password": "pass", "scopes": ["read:posts", "write:posts"]}
# 4. Check if token was issued with scopes# When creating token:jwt.encode({ 'user_id': 123, 'scopes': ['read:posts', 'write:posts'], 'exp': datetime.utcnow() + timedelta(hours=1)}, secret, algorithm='HS256')
# 5. Verify query/mutation requires scope# Check @fraiseql.query/@fraiseql.mutation decoratorsToken Issues
Section titled “Token Issues”Token Revoked
Section titled “Token Revoked”Problem: Error: Token has been revoked
Cause: User logged out or token explicitly invalidated
Solutions:
# 1. Implement token blacklistCREATE TABLE revoked_tokens ( token_id VARCHAR(255) PRIMARY KEY, revoked_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP);
# 2. On logout, add token to blacklistINSERT INTO revoked_tokens (token_id, expires_at)VALUES ('token_value', NOW() + INTERVAL '1 hour');
# 3. Check before accepting tokenSELECT COUNT(*) FROM revoked_tokensWHERE token_id = 'token_value' AND expires_at > NOW();
# 4. Clean up expired tokens periodicallyDELETE FROM revoked_tokens WHERE expires_at < NOW();# 1. Implement token blacklistCREATE TABLE revoked_tokens ( token_id VARCHAR(255) PRIMARY KEY, revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP);
# 2. On logout, add token to blacklistINSERT INTO revoked_tokens (token_id, expires_at)VALUES ('token_value', TIMESTAMPADD(HOUR, 1, NOW()));
# 3. Check before accepting tokenSELECT COUNT(*) FROM revoked_tokensWHERE token_id = 'token_value' AND expires_at > NOW();
# 4. Clean up expired tokens periodicallyDELETE FROM revoked_tokens WHERE expires_at < NOW();# 1. Implement token blacklistCREATE TABLE revoked_tokens ( token_id TEXT PRIMARY KEY, revoked_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME);
# 2. On logout, add token to blacklistINSERT INTO revoked_tokens (token_id, expires_at)VALUES ('token_value', datetime('now', '+1 hour'));
# 3. Check before accepting tokenSELECT COUNT(*) FROM revoked_tokensWHERE token_id = 'token_value' AND expires_at > datetime('now');
# 4. Clean up expired tokens periodicallyDELETE FROM revoked_tokens WHERE expires_at < datetime('now');# 1. Implement token blacklistCREATE TABLE revoked_tokens ( token_id VARCHAR(255) PRIMARY KEY, revoked_at DATETIME DEFAULT GETUTCDATE(), expires_at DATETIME);
# 2. On logout, add token to blacklistINSERT INTO revoked_tokens (token_id, expires_at)VALUES ('token_value', DATEADD(HOUR, 1, GETUTCDATE()));
# 3. Check before accepting tokenSELECT COUNT(*) FROM revoked_tokensWHERE token_id = 'token_value' AND expires_at > GETUTCDATE();
# 4. Clean up expired tokens periodicallyDELETE FROM revoked_tokens WHERE expires_at < GETUTCDATE();5. Or use short-lived tokens + refresh
Section titled “5. Or use short-lived tokens + refresh”If token revoked, create new token after 1 hour max
Section titled “If token revoked, create new token after 1 hour max”Wrong Audience
Section titled “Wrong Audience”Problem: Error: Invalid token audience
Cause: Token issued for different app/audience
Solutions:
# 1. Check token audiencedecoded = jwt.decode(token, options={'verify_signature': False})aud = decoded.get('aud') # Should match app ID
# 2. When issuing token, set audiencejwt.encode({ 'user_id': 123, 'aud': 'fraiseql-api', # Specify audience 'exp': ...}, secret)
# 3. When verifying, check audiencejwt.decode(token, secret, algorithms=['HS256'], audience='fraiseql-api')
# 4. For multiple apps, each gets different audience# App A: audience='app-a'# App B: audience='app-b'# Token for app-a won't work in app-bData Access Issues
Section titled “Data Access Issues”Unauthorized Data Access
Section titled “Unauthorized Data Access”Problem: User can access data they shouldn’t
Cause: Missing row-level security or authorization
Solutions:
- Implement row-level security in your FraiseQL queries:
@fraiseql.querydef user_posts(user_id: ID) -> list[Post]: # WRONG: Returns all posts # SELECT * FROM posts
# RIGHT: Returns only user's posts # SELECT * FROM posts WHERE owner_id = current_user_id()- For database-level RLS policies:
# Check existing RLS policiesSELECT * FROM pg_policies;
# Enable RLS on tableALTER TABLE posts ENABLE ROW LEVEL SECURITY;
# Create tenant isolation policyCREATE POLICY posts_tenant_isolation ON postsUSING (user_id = current_setting('app.user_id')::integer);
# Set user context before querySET app.user_id = 123;SELECT * FROM posts; -- Returns only user 123's posts# MySQL doesn't have RLS, use views or application layer# Create filtered view for user's postsCREATE VIEW user_posts_view ASSELECT * FROM postsWHERE user_id = @current_user_id;
# Set user context as session variableSET @current_user_id = 123;SELECT * FROM user_posts_view; -- Returns only user 123's posts# SQLite doesn't have RLS, use triggers or views# Create filtered view for user's postsCREATE VIEW user_posts_view ASSELECT * FROM postsWHERE user_id = ( SELECT value FROM context WHERE key = 'current_user_id');
# Store context in context tableUPDATE context SET value = '123' WHERE key = 'current_user_id';SELECT * FROM user_posts_view; -- Returns user 123's posts# SQL Server doesn't have RLS in standard edition,# but Enterprise has static/dynamic RLS
# For standard edition, use views or stored proceduresCREATE VIEW user_posts_view ASSELECT * FROM postsWHERE user_id = SESSION_CONTEXT(N'user_id');
-- Set user context before queryEXEC sp_set_session_context @key = N'user_id', @value = 123;SELECT * FROM user_posts_view; -- Returns user 123's posts- For FraiseQL, pass the user ID from the JWT directly into the SQL view using the
inject=parameter:
# JWT 'sub' claim is injected as a SQL parameter into the view/function@fraiseql.query( sql_source="v_post", inject={"user_id": "jwt:sub"})def posts() -> list[Post]: passThe PostgreSQL view or RLS policy can then reference user_id directly, without any middleware layer.
SQL Injection via Input
Section titled “SQL Injection via Input”Problem: Malicious SQL executed from user input
Cause: Unsanitized input in queries
Solutions:
# WRONG: Vulnerable to injectionquery = f"SELECT * FROM users WHERE email = '{email}'"# If email = "'; DROP TABLE users; --"# Query becomes: SELECT * FROM users WHERE email = ''; DROP TABLE users; --'
# RIGHT: Use parameterized queries@fraiseql.querydef user_by_email(email: str) -> User: # FraiseQL automatically parameterizes # Equivalent to: SELECT * FROM users WHERE email = $1 pass
# 3. Never concatenate user input into queries# Always use parameterized queries/ORM
# 4. Validate input formatimport reif not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email): raise ValueError('Invalid email format')API Key Issues
Section titled “API Key Issues”Invalid API Key
Section titled “Invalid API Key”Problem: Error: Invalid API key
Solutions:
# 1. Check API key format# Should be: sk_xxxxxxxxxxxxx (starts with sk_)
# 2. Create new key if lostPOST /api/keysResponse: { "key": "sk_..." }
# 3. Rotate keys periodically# Create new key, migrate clients, revoke old key# Verify key is activeSELECT * FROM api_keysWHERE key = 'sk_...' AND revoked = false;
# Check key permissionsSELECT scopes FROM api_keys WHERE key = 'sk_...';# Verify key is activeSELECT * FROM api_keysWHERE `key` = 'sk_...' AND revoked = 0;
# Check key permissionsSELECT scopes FROM api_keys WHERE `key` = 'sk_...';# Verify key is activeSELECT * FROM api_keysWHERE key = 'sk_...' AND revoked = 0;
# Check key permissionsSELECT scopes FROM api_keys WHERE key = 'sk_...';# Verify key is activeSELECT * FROM api_keysWHERE [key] = 'sk_...' AND revoked = 0;
# Check key permissionsSELECT scopes FROM api_keys WHERE [key] = 'sk_...';API Key Exposure
Section titled “API Key Exposure”Problem: Key was leaked/compromised
Cause: Key committed to git, exposed in logs, etc.
Solutions:
# 1. IMMEDIATELY revoke keyUPDATE api_keys SET revoked = true WHERE key = 'sk_...';# 1. IMMEDIATELY revoke keyUPDATE api_keys SET revoked = 1 WHERE `key` = 'sk_...';# 1. IMMEDIATELY revoke keyUPDATE api_keys SET revoked = 1 WHERE key = 'sk_...';# 1. IMMEDIATELY revoke keyUPDATE api_keys SET revoked = 1 WHERE [key] = 'sk_...';# 2. Create new keyPOST /api/keys
# 3. Update all clients with new key
# 4. Scan git history (if committed)git log -S "sk_" --oneline # Find commits with keygit filter-branch --force --index-filter "git rm -rf --cached --ignore-unmatch .env" HEAD
# 5. Prevent in future# Add to .gitignoreecho ".env" >> .gitignoreecho "*.key" >> .gitignore
# 6. Use secrets management# AWS Secrets Manager, Google Secret Manager, Azure Key Vault# Not as environment variablesSSL/TLS Issues
Section titled “SSL/TLS Issues”SSL Certificate Invalid
Section titled “SSL Certificate Invalid”Problem: Error: certificate verify failed
Cause: SSL certificate issue
Solutions:
# 1. Check certificate validityopenssl x509 -in cert.pem -noout -dates# Verify: notAfter is in future
# 2. Check certificate chainopenssl verify -CAfile ca.pem cert.pem
# 3. For self-signed certificates (development only)openssl x509 -in cert.pem -noout -subject# Should match your hostname
# 4. For production, use valid certificate# Let's Encrypt (free)# DigiCert, Comodo, etc. (paid)
# 5. Update certificate before expiration# Get alerts 30 days before expiry```python
### Mixed Content Error**Problem**: `Error: Mixed Content: The page at was loaded over HTTPS, but requested an insecure resource`
**Cause**: HTTPS page loading from HTTP endpoint
**Solutions**:```bash# 1. Use HTTPS everywhere# Frontend: https://app.example.com# API: https://api.example.com
# 2. Add HSTS header# Tells browser: Always use HTTPSadd_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 3. Redirect HTTP to HTTPSserver { listen 80; return 301 https://$server_name$request_uri;}
# 4. Check redirect chaincurl -L -I http://api.example.com# Should eventually reach https://Rate Limiting Security
Section titled “Rate Limiting Security”Rate Limit Bypass
Section titled “Rate Limit Bypass”Problem: Attacker bypassing rate limits
Causes:
- Using multiple IPs
- Distributed attack
- Rate limit not enabled
Solutions:
# 1. Enable rate limitingRATE_LIMIT_REQUESTS=1000RATE_LIMIT_WINDOW_SECONDS=60
# 2. Use per-user limit (requires auth)PER_USER_RATE_LIMIT=100PER_USER_RATE_LIMIT_WINDOW_SECONDS=60
# 3. For anonymous users, use per-IP# But be careful: shared ISP proxies affect multiple users
# 4. Implement progressive delays# First 100: instant# 100-200: 100ms delay# 200-300: 500ms delay# Slows down attacks without blocking legitimate users
# 5. Use CAPTCHA for suspicious traffic# After N failed attempts, require CAPTCHAData Exposure
Section titled “Data Exposure”Sensitive Data in Logs
Section titled “Sensitive Data in Logs”Problem: Passwords, tokens, PII in logs
Solutions:
# 1. Configure error sanitization in fraiseql.toml — FraiseQL redacts# sensitive fields from error responses and logs automatically.# [security.error_sanitization]# sanitize_errors = true
# 2. Check logs for sensitive datasudo grep -r "password\|token\|ssn" /var/log/
# 3. Rotate any exposed secrets# Change all passwords, issue new tokens
# 4. Set log retention# Keep logs for X days, then deleteLOG_RETENTION_DAYS=30
# 5. Encrypt logs at rest# Use TLS for log transmission# Encrypt log file storagePII Exposure in Error Messages
Section titled “PII Exposure in Error Messages”Problem: Personal data revealed in error responses
Solutions:
# WRONG: Exposes user email@fraiseql.mutationdef send_email(email: str): user = find_user_by_email(email) # Error: "No user found with email: alice@example.com" # Attacker learns valid emails
# RIGHT: Generic error@fraiseql.mutationdef send_email(email: str): user = find_user_by_email(email) # Error: "Email not found" # Or: "If account exists, email will be sent"
# 2. Check error messages don't leak datacurl -X POST http://localhost:8000/graphql -d '{"query":"..."}'# Check response for: email, name, phone, SSN, etc.
# 3. In production, use generic error messages in PostgreSQL functions:# RAISE EXCEPTION 'Resource not found' USING ERRCODE = 'P0001', HINT = 'NOT_FOUND';# FraiseQL forwards the HINT as the error code — never include user-identifying# details in the exception message.Summary Table
Section titled “Summary Table”| Issue | Severity | Fix Time |
|---|---|---|
| Invalid token | HIGH | 2 min |
| Missing header | HIGH | 1 min |
| Permission denied | HIGH | 5 min |
| Token expired | MEDIUM | 1 min |
| CORS failed | MEDIUM | 5 min |
| SSL error | HIGH | 10 min |
| Rate limit bypass | MEDIUM | 15 min |
| PII in logs | CRITICAL | 30 min |