Skip to content

Security Issues

Solutions for FraiseQL security and authentication problems.

Problem: Error: Invalid token

Causes:

  • Token expired
  • Token signed with different secret
  • Malformed token
  • Token from different issuer

Solutions:

Terminal window
# 1. Decode token to check (without verification)
python3 -c "
import jwt
import json
token = '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 expired
python3 -c "
from datetime import datetime
import jwt
decoded = 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 matches
echo $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.signature

Problem: Error: Missing Authorization header

Cause: Client not sending authentication

Solutions:

Terminal window
# 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 curl
curl -H "Authorization: Bearer ${TOKEN}" http://localhost:8000/graphql
# 4. Check if endpoint requires auth
# Some endpoints might be public (@public decorator)
@fraiseql.query
def public_posts() -> list[Post]:
pass
# 5. Check auth middleware is enabled
# FraiseQL should automatically extract token from header
# Verify in logs: LOG_LEVEL=debug

Problem: Error: Token has expired

Solutions:

Terminal window
# 1. Check token expiration
decoded = 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.mutation
def 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 minutes

Problem: Error: CORS policy: Response to preflight request

Cause: CORS configuration incorrect

Solutions:

Terminal window
# 1. Check CORS_ORIGINS
echo $CORS_ORIGINS
# Should include your frontend origin
# 2. Add frontend origin if missing
CORS_ORIGINS=https://app.example.com,https://localhost:3000
# 3. Test preflight request
curl -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 origins
CORS_ORIGINS=* # Allow all (development only!)
# 6. For production
CORS_ORIGINS=https://app.example.com,https://www.example.com

Problem: Error: User does not have permission

Cause: User role lacks required permission

Solutions:

Terminal window
# 1. Check user's current role
SELECT role FROM users WHERE id = 123;
# 2. Check what permissions role has
SELECT rp.permission
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
WHERE ur.user_id = 123;
# 3. Add missing permission
INSERT 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')
);
# 4. Check query requires correct scope
@fraiseql.query(requires_scope="read:posts")
def posts() -> list[Post]:
pass
# 5. Verify user token includes required scope
decoded = jwt.decode(token, options={'verify_signature': False})
scopes = decoded.get('scopes', [])
# Should include required scope

Problem: Error: Insufficient scope for this operation

Cause: Token doesn’t have required permission

Solutions:

Terminal window
# 1. Check token scopes
python3 -c "
import jwt
token = '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 scopes
POST /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 decorators

Problem: Error: Token has been revoked

Cause: User logged out or token explicitly invalidated

Solutions:

Terminal window
# 1. Implement token blacklist
CREATE TABLE revoked_tokens (
token_id VARCHAR(255) PRIMARY KEY,
revoked_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP
);
# 2. On logout, add token to blacklist
INSERT INTO revoked_tokens (token_id, expires_at)
VALUES ('token_value', NOW() + INTERVAL '1 hour');
# 3. Check before accepting token
SELECT COUNT(*) FROM revoked_tokens
WHERE token_id = 'token_value' AND expires_at > NOW();
# 4. Clean up expired tokens periodically
DELETE FROM revoked_tokens WHERE expires_at < NOW();

If token revoked, create new token after 1 hour max

Section titled “If token revoked, create new token after 1 hour max”

Problem: Error: Invalid token audience

Cause: Token issued for different app/audience

Solutions:

Terminal window
# 1. Check token audience
decoded = jwt.decode(token, options={'verify_signature': False})
aud = decoded.get('aud') # Should match app ID
# 2. When issuing token, set audience
jwt.encode({
'user_id': 123,
'aud': 'fraiseql-api', # Specify audience
'exp': ...
}, secret)
# 3. When verifying, check audience
jwt.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-b

Problem: User can access data they shouldn’t

Cause: Missing row-level security or authorization

Solutions:

  1. Implement row-level security in your FraiseQL queries:
@fraiseql.query
def 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()
  1. For database-level RLS policies:
Terminal window
# Check existing RLS policies
SELECT * FROM pg_policies;
# Enable RLS on table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
# Create tenant isolation policy
CREATE POLICY posts_tenant_isolation ON posts
USING (user_id = current_setting('app.user_id')::integer);
# Set user context before query
SET app.user_id = 123;
SELECT * FROM posts; -- Returns only user 123's posts
  1. 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]:
pass

The PostgreSQL view or RLS policy can then reference user_id directly, without any middleware layer.

Problem: Malicious SQL executed from user input

Cause: Unsanitized input in queries

Solutions:

Terminal window
# WRONG: Vulnerable to injection
query = 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.query
def 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 format
import re
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
raise ValueError('Invalid email format')

Problem: Error: Invalid API key

Solutions:

Terminal window
# 1. Check API key format
# Should be: sk_xxxxxxxxxxxxx (starts with sk_)
# 2. Create new key if lost
POST /api/keys
Response: { "key": "sk_..." }
# 3. Rotate keys periodically
# Create new key, migrate clients, revoke old key
Terminal window
# Verify key is active
SELECT * FROM api_keys
WHERE key = 'sk_...' AND revoked = false;
# Check key permissions
SELECT scopes FROM api_keys WHERE key = 'sk_...';

Problem: Key was leaked/compromised

Cause: Key committed to git, exposed in logs, etc.

Solutions:

Terminal window
# 1. IMMEDIATELY revoke key
UPDATE api_keys SET revoked = true WHERE key = 'sk_...';
Terminal window
# 2. Create new key
POST /api/keys
# 3. Update all clients with new key
# 4. Scan git history (if committed)
git log -S "sk_" --oneline # Find commits with key
git filter-branch --force --index-filter "git rm -rf --cached --ignore-unmatch .env" HEAD
# 5. Prevent in future
# Add to .gitignore
echo ".env" >> .gitignore
echo "*.key" >> .gitignore
# 6. Use secrets management
# AWS Secrets Manager, Google Secret Manager, Azure Key Vault
# Not as environment variables

Problem: Error: certificate verify failed

Cause: SSL certificate issue

Solutions:

Terminal window
# 1. Check certificate validity
openssl x509 -in cert.pem -noout -dates
# Verify: notAfter is in future
# 2. Check certificate chain
openssl 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 HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 3. Redirect HTTP to HTTPS
server {
listen 80;
return 301 https://$server_name$request_uri;
}
# 4. Check redirect chain
curl -L -I http://api.example.com
# Should eventually reach https://

Problem: Attacker bypassing rate limits

Causes:

  • Using multiple IPs
  • Distributed attack
  • Rate limit not enabled

Solutions:

Terminal window
# 1. Enable rate limiting
RATE_LIMIT_REQUESTS=1000
RATE_LIMIT_WINDOW_SECONDS=60
# 2. Use per-user limit (requires auth)
PER_USER_RATE_LIMIT=100
PER_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 CAPTCHA

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 data
sudo 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 delete
LOG_RETENTION_DAYS=30
# 5. Encrypt logs at rest
# Use TLS for log transmission
# Encrypt log file storage

Problem: Personal data revealed in error responses

Solutions:

Terminal window
# WRONG: Exposes user email
@fraiseql.mutation
def 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.mutation
def 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 data
curl -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.

IssueSeverityFix Time
Invalid tokenHIGH2 min
Missing headerHIGH1 min
Permission deniedHIGH5 min
Token expiredMEDIUM1 min
CORS failedMEDIUM5 min
SSL errorHIGH10 min
Rate limit bypassMEDIUM15 min
PII in logsCRITICAL30 min