OAuth Token Security: Lessons from Recent Incidents

April 18, 2022

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

Your tokens are keys. Treat them accordingly.