Recent incidents have shown that OAuth tokens—the keys to your kingdom—are attractive targets. The Heroku/GitHub incident exposed thousands of private repositories through compromised OAuth tokens. These aren’t theoretical risks; they’re actively exploited.
Here’s how to secure your OAuth token implementations.
The Token Compromise Problem
Recent Incidents
recent_incidents:
heroku_github_2022:
what: OAuth tokens for Heroku integration stolen
impact:
- Private repo access
- npm packages potentially affected
- Customer data exposure
root_cause: Compromised Heroku database
codecov_2021:
what: Bash uploader modified to steal credentials
impact:
- CI/CD secrets harvested
- OAuth tokens among stolen credentials
duration: Months before detection
lesson: |
OAuth tokens are high-value targets.
Compromised tokens = compromised access.
Why Tokens Are Risky
token_risks:
persistence:
- Tokens often long-lived
- Access until revoked
- No password change to invalidate
scope_creep:
- Broad permissions requested
- Users grant without review
- More access than needed
storage:
- Stored in databases
- Sometimes in logs
- Third-party systems
detection:
- Token use looks legitimate
- Hard to distinguish from real user
- May never notice compromise
Token Security Practices
Short-Lived Tokens
token_lifetime:
access_tokens:
recommendation: 15 minutes to 1 hour
rationale: Limits exposure window
tradeoff: More refresh operations
refresh_tokens:
recommendation: Hours to days, not months
storage: Secure, encrypted storage
rotation: Rotate on use
anti_pattern:
what: Tokens that never expire
risk: Compromised token = permanent access
# Token with short expiry
def create_access_token(user):
return jwt.encode({
'sub': user.id,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=15), # Short!
'scope': user.approved_scopes
}, SECRET_KEY)
def create_refresh_token(user):
token = RefreshToken(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + timedelta(days=7), # Reasonable
created_at=datetime.utcnow()
)
db.session.add(token)
db.session.commit()
return token
Minimal Scopes
scope_principles:
request_minimum:
- Only scopes actually needed
- Not "just in case" scopes
- Review scope requests
granular_scopes:
bad: "repo" (full access)
good: "repo:read" (read only)
user_transparency:
- Show what access is requested
- Allow partial approval where possible
- Easy revocation
# Request minimal scopes
GITHUB_SCOPES = [
'read:user', # Read user profile
'repo:status', # Read commit status
# NOT 'repo' - we don't need full access
]
def build_oauth_url():
return f"https://github.com/login/oauth/authorize?" + urlencode({
'client_id': CLIENT_ID,
'scope': ' '.join(GITHUB_SCOPES),
'state': generate_state_token()
})
Token Rotation
# Rotate refresh tokens on use
def refresh_access_token(refresh_token_value):
old_token = RefreshToken.query.filter_by(
token=refresh_token_value,
revoked=False
).first()
if not old_token or old_token.is_expired:
raise InvalidTokenError()
# Revoke old refresh token
old_token.revoked = True
old_token.revoked_at = datetime.utcnow()
# Create new refresh token
new_refresh_token = create_refresh_token(old_token.user)
# Create new access token
access_token = create_access_token(old_token.user)
db.session.commit()
return {
'access_token': access_token,
'refresh_token': new_refresh_token.token,
'expires_in': 900 # 15 minutes
}
Secure Storage
token_storage:
encryption:
- Encrypt tokens at rest
- Use envelope encryption
- Key rotation capability
database:
- Store hashes for verification tokens
- Encrypt tokens that need retrieval
- Separate from user data if possible
never:
- Plain text in logs
- Version control
- Client-side storage for sensitive tokens
- URL parameters
# Encrypted token storage
from cryptography.fernet import Fernet
class TokenStorage:
def __init__(self, encryption_key):
self.cipher = Fernet(encryption_key)
def store_token(self, user_id, token):
encrypted = self.cipher.encrypt(token.encode())
OAuthToken.create(
user_id=user_id,
encrypted_token=encrypted,
# Store hash for lookup without decryption
token_hash=hashlib.sha256(token.encode()).hexdigest()
)
def get_token(self, user_id):
record = OAuthToken.query.filter_by(user_id=user_id).first()
if record:
return self.cipher.decrypt(record.encrypted_token).decode()
return None
Detection and Response
Monitoring
token_monitoring:
usage_patterns:
- Geographic anomalies
- Time-based anomalies
- Volume anomalies
- New IP addresses
alerts:
- Token used from new location
- Unusual API call patterns
- High volume requests
- Access to sensitive resources
# Token usage monitoring
def track_token_usage(token, request):
usage = TokenUsage(
token_id=token.id,
ip_address=request.remote_addr,
user_agent=request.user_agent.string,
endpoint=request.path,
timestamp=datetime.utcnow()
)
db.session.add(usage)
# Check for anomalies
if is_anomalous(token, usage):
alert_security_team(token, usage)
# Consider: automatic revocation
Revocation Capability
revocation_requirements:
user_initiated:
- Users can revoke anytime
- Clear UI for managing tokens
- See what's authorized
admin_initiated:
- Mass revocation capability
- Revoke by application
- Audit trail
automatic:
- On suspicious activity
- On password change
- On security incident
# Mass revocation
def revoke_tokens_for_application(app_id, reason):
tokens = OAuthToken.query.filter_by(
application_id=app_id,
revoked=False
).all()
for token in tokens:
token.revoked = True
token.revoked_at = datetime.utcnow()
token.revocation_reason = reason
db.session.commit()
# Notify affected users
for token in tokens:
notify_user_of_revocation(token.user, reason)
# Log for audit
audit_log.info(f"Revoked {len(tokens)} tokens for app {app_id}: {reason}")
Incident Response
token_compromise_response:
immediate:
- Revoke compromised tokens
- Notify affected users
- Assess scope of access
investigation:
- Determine what was accessed
- Timeline of compromise
- Source of breach
remediation:
- Fix vulnerability
- Force token refresh
- Review access logs
communication:
- User notification
- Regulatory notification if required
- Public disclosure if appropriate
Architecture Improvements
Token Binding
token_binding:
concept: Bind tokens to specific clients
mechanisms:
- DPoP (Demonstrating Proof of Possession)
- Certificate-bound tokens
- MTLS client authentication
benefit: Stolen token unusable without client proof
Zero Standing Privileges
zero_standing_privileges:
concept: No persistent access tokens
implementation:
- Request access when needed
- Very short-lived tokens
- Automatic expiration
- Re-authentication for sensitive actions
trade_off: More auth operations, better security
Key Takeaways
- OAuth tokens are high-value targets—recent incidents prove it
- Use short-lived access tokens (15 min to 1 hour)
- Request minimal scopes; don’t ask for more than needed
- Rotate refresh tokens on every use
- Encrypt tokens at rest; never log them
- Monitor token usage for anomalies
- Maintain easy revocation capability
- Have incident response plan for token compromise
- Consider token binding for high-security scenarios
- Audit OAuth integrations regularly
Your tokens are keys. Treat them accordingly.