Secret Scanning with GitHub Advanced Security
Secret scanning GitHub Advanced Security detects credentials, API keys, tokens, and other secrets accidentally committed to repositories. In 2026, with over 200 supported secret types and push protection that blocks commits containing secrets before they reach the repository, this feature has become an essential layer of defense against credential leaks that lead to data breaches. Importantly, it operates at two stages: it scans existing history retroactively, and it intercepts new pushes proactively, so both legacy debt and fresh mistakes are covered.
This guide covers implementing comprehensive secret scanning across your organization, including push protection configuration, custom secret patterns, incident response procedures, and integration with secret management tools like HashiCorp Vault and AWS Secrets Manager. Along the way, it explains how partner-validated alerts differ from pattern matches, why rotation matters more than deletion, and where the tool stops being enough on its own.
The Cost of Leaked Secrets
A single leaked AWS access key can result in hundreds of thousands of dollars in unauthorized charges within hours, typically because automated bots scrape public commits and immediately spin up cryptomining instances. Moreover, leaked database credentials expose customer data, triggering compliance violations and breach notification requirements under regimes like GDPR and CCPA. GitHub reports that over 10 million secrets are leaked in public repositories annually — and private repositories are equally at risk from insiders, forks, and compromised developer machines.
The deeper problem is that git history is effectively permanent. Even after you delete a secret in a follow-up commit, the original value remains reachable through the commit SHA, reflogs, and any clone or fork that already pulled it. Consequently, deletion alone never resolves a leak — the credential must be treated as burned and rotated. This is why detection speed is the single most important metric: the exposure window between commit and rotation is exactly the window an attacker has to act.
Common Secret Types Detected
┌────────────────────────────┬────────────┬──────────────┐
│ Secret Type │ Severity │ Detection │
├────────────────────────────┼────────────┼──────────────┤
│ AWS Access Keys │ Critical │ Built-in │
│ Azure Service Principal │ Critical │ Built-in │
│ GCP Service Account Key │ Critical │ Built-in │
│ GitHub PAT/OAuth Token │ High │ Built-in │
│ Database Connection String │ Critical │ Built-in │
│ Private SSH Keys │ High │ Built-in │
│ Stripe API Keys │ High │ Built-in │
│ Slack Webhook URLs │ Medium │ Built-in │
│ JWT Signing Keys │ High │ Custom │
│ Internal API Keys │ Medium │ Custom │
│ Encryption Keys │ Critical │ Custom │
└────────────────────────────┴────────────┴──────────────┘
200+ built-in patterns + custom patterns
Validated Alerts vs Pattern Matches
Not every detection carries the same confidence, and understanding the difference is key to triaging quickly. For many partner secret types — Stripe, AWS, GitHub tokens, and others — GitHub participates in a partner program where the detected string is sent to the issuing provider, which confirms whether the credential is live. As a result, these “validity check” alerts tell you not just that a pattern matched but that the secret is currently active and exploitable. By contrast, generic pattern matches (especially custom regexes) may produce false positives that look like secrets but are placeholders or expired values.
In practice, teams prioritize triage in this order: active validated alerts first, then unvalidated high-severity matches, then medium and low matches awaiting review. Furthermore, GitHub surfaces a validity status field on each alert (`active`, `inactive`, or `unknown`) that automation can read to skip rotation for already-revoked credentials. This avoids the noisy trap of treating every regex hit as a five-alarm fire, which is the fastest way to get a security team to start ignoring alerts altogether.
Enabling Secret Scanning
# Enable for a single repository via API
gh api repos/{owner}/{repo} \
--method PATCH \
--field security_and_analysis.secret_scanning.status=enabled \
--field security_and_analysis.secret_scanning_push_protection.status=enabled
# Enable for entire organization
gh api orgs/{org} \
--method PATCH \
--field security_and_analysis.secret_scanning.status=enabled \
--field security_and_analysis.secret_scanning_push_protection.status=enabled
For organizations, the recommended pattern is to enable scanning by default for all new repositories so coverage does not depend on developers remembering to opt in. Additionally, you can roll out enforcement gradually: enable detection-only mode across the fleet first to understand the existing debt, then turn on push protection once teams have cleaned up and adjusted their workflows. This staged approach reduces the friction that causes developers to look for bypasses.
Push Protection Configuration
Push protection is the most valuable feature — it blocks commits containing secrets before they are pushed. Therefore, secrets never reach the repository and never appear in git history. When a developer attempts to push a blocked secret, git rejects the push with a message identifying the secret type and location, and the developer must either remove it or explicitly bypass with a documented reason:
# .github/secret_scanning.yml — Organization-level config
paths-ignore:
- 'test/fixtures/**'
- '**/*.test.js'
- 'docs/examples/**'
# Custom patterns for internal secrets
patterns:
- name: Internal API Key
secret_type: internal_api_key
pattern: 'MYAPP_KEY_[A-Za-z0-9]{32}'
- name: Internal JWT Secret
secret_type: jwt_secret
pattern: 'JWT_SECRET=[A-Za-z0-9+/=]{44,}'
- name: Database URL
secret_type: database_url
pattern: '(postgres|mysql|mongodb)://[^\s]+:[^\s]+@[^\s]+'
A critical governance decision is who may bypass push protection and what happens when they do. The docs recommend restricting bypass permissions to a small set of trusted roles and routing every bypass to an audit log and a real-time alert. When a bypass occurs, the secret has by definition entered the repository, so the bypass event should automatically open an incident rather than silently succeed. In short, treat a bypass not as an escape hatch but as a tripwire.
Custom Secret Patterns
Organizations often have internal credential formats that built-in patterns do not cover. Additionally, custom patterns let you detect company-specific secrets and configuration values. The art of writing them is balancing specificity against recall: too loose and you drown in false positives, too tight and you miss real leaks. A good rule is to anchor on a distinctive, vendor-specific prefix plus a fixed-length character class, which is exactly why credential formats with prefixes like `svc_tok_` are far easier to scan reliably than bare hex blobs.
# Create custom pattern via API
gh api orgs/{org}/secret-scanning/custom-patterns \
--method POST \
--field name="Internal Service Token" \
--field pattern="svc_tok_[a-f0-9]{40}" \
--field description="Internal microservice authentication token" \
--field severity="high"
# Create pattern with test strings for validation
gh api orgs/{org}/secret-scanning/custom-patterns \
--method POST \
--field name="Encryption Key" \
--field pattern="ENC_KEY_[A-Za-z0-9]{64}" \
--field description="Application encryption key" \
--field severity="critical" \
--field test_strings='["ENC_KEY_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]'
Before publishing a custom pattern organization-wide, run it in a dry-run mode against existing repositories. GitHub lets you preview matches so you can tune the regex against real code before it starts generating alerts and blocking pushes. This is the single most effective way to avoid a Monday morning where every team’s CI is failing on a poorly written pattern that matches innocuous strings.
Incident Response Workflow
When a secret is detected, speed matters. Consequently, automate the response process to minimize the exposure window. The canonical sequence is revoke, rotate, then investigate — in that order. Revoking first stops the bleeding even before you understand the full blast radius, because an inactive credential cannot be abused regardless of who already cloned it:
import requests
import json
from datetime import datetime
class SecretIncidentHandler:
def __init__(self, github_token, vault_url, slack_webhook):
self.headers = {
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github+json',
}
self.vault_url = vault_url
self.slack_webhook = slack_webhook
def handle_alert(self, alert):
"""Process a secret scanning alert."""
secret_type = alert['secret_type']
severity = self.classify_severity(secret_type)
# Skip rotation if the provider already reports it inactive
if alert.get('validity') == 'inactive':
self.resolve_alert(alert['number'], 'revoked')
return
# Step 1: Rotate the compromised credential
if secret_type.startswith('aws_'):
self.rotate_aws_key(alert)
elif secret_type == 'database_url':
self.rotate_db_credential(alert)
# Step 2: Alert the team
self.notify_slack(alert, severity)
# Step 3: Resolve the alert
self.resolve_alert(alert['number'], 'revoked')
# Step 4: Log for compliance
self.log_incident(alert, severity)
def rotate_aws_key(self, alert):
"""Automatically rotate compromised AWS keys."""
import boto3
iam = boto3.client('iam')
# Deactivate the leaked key immediately
access_key = alert['secret'][:20] # AKIA...
iam.update_access_key(
AccessKeyId=access_key,
Status='Inactive'
)
# Create new key and store in Vault
new_key = iam.create_access_key(
UserName=alert.get('push_protection_bypassed_by',
{}).get('login', 'unknown')
)
self.store_in_vault(
f"aws/{alert['repository']['name']}",
new_key['AccessKey']
)
def notify_slack(self, alert, severity):
"""Send incident notification to security channel."""
color = {'critical': '#ff0000', 'high': '#ff8800',
'medium': '#ffcc00'}.get(severity, '#cccccc')
requests.post(self.slack_webhook, json={
'attachments': [{
'color': color,
'title': f'Secret Leaked: {alert["secret_type"]}',
'fields': [
{'title': 'Repository',
'value': alert['repository']['full_name']},
{'title': 'Severity', 'value': severity},
{'title': 'Committer',
'value': alert.get('push_protection_bypassed_by',
{}).get('login', 'Unknown')},
],
}]
})
Automated rotation is powerful but demands guardrails. For instance, blindly deactivating an AWS key that a production service still references can cause an outage worse than the leak itself. For that reason, mature teams scope automated rotation to credential classes that are already managed dynamically — short-lived tokens, per-repository service accounts, and Vault-issued secrets — while routing long-lived shared credentials to a human-in-the-loop runbook. The goal is fast, safe rotation, not fast, reckless rotation.
Webhooks vs Polling for Alert Delivery
There are two ways to feed alerts into the handler above, and the choice affects your exposure window directly. Polling the alerts API on a schedule is simple to build but introduces latency equal to your poll interval — if you poll every fifteen minutes, an attacker gets up to fifteen free minutes. Webhooks, by contrast, fire the moment GitHub creates an alert, typically within seconds of the push. The trade-off is operational: webhooks require a publicly reachable, authenticated endpoint that validates the signature header and handles retries idempotently. In production teams the common pattern is webhooks as the primary path with a periodic reconciliation poll as a safety net, so a missed delivery never means a missed leak.
Pre-Commit Prevention
The best approach is preventing secrets from being committed in the first place. Furthermore, pre-commit hooks provide a local safety net before code reaches GitHub, which matters because push protection only triggers at push time — a local hook catches the secret at commit time, before it ever enters even your own history:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
name: Detect secrets with Gitleaks
entry: gitleaks protect --staged --verbose
language: golang
- repo: https://github.com/trufflesecurity/trufflehog
rev: v3.63.0
hooks:
- id: trufflehog
name: TruffleHog secret scan
entry: trufflehog git file://. --only-verified --fail
# .gitleaks.toml — Custom rules
[extend]
useDefault = true
[[rules]]
id = "internal-api-key"
description = "Internal API key detected"
regex = '''MYAPP_KEY_[A-Za-z0-9]{32}'''
secretGroup = 0
[[rules]]
id = "env-file-secret"
description = "Secret in environment variable assignment"
regex = '''(?i)(password|secret|api_key|token)s*=s*['"][^'"]{8,}['"]'''
secretGroup = 0
[rules.allowlist]
paths = [
'''.example