API Key Security Testing: Rotation, Scoping, and Leakage Detection
API keys are credentials. They authenticate your payment processor, your SMS provider, your analytics pipeline, your internal microservices. Unlike passwords, they're often stored in plaintext, shared across environments, committed to version control, and embedded in frontend JavaScript where anyone with a browser can read them.
The 2023 CircleCI breach started with a compromised API token in a developer's laptop session. The 2022 Toyota data exposure leaked 215GB of data because a GitHub repository contained a hardcoded AWS API key for nearly five years. Twilio, Mailchimp, and Slack have all traced incidents back to exposed API credentials. This is not a theoretical risk category — it's one of the most consistently exploited attack surfaces in production SaaS environments.
This guide covers the full API key security testing workflow: finding leaked keys before attackers do, verifying that keys are scoped to the minimum required permissions, testing rotation and revocation policies, and auditing key transmission security across HTTP traffic.
The API Key Threat Model
Before testing, map the complete lifecycle of API keys in your environment:
| Lifecycle Stage | Where Keys Appear | Common Failure |
|---|---|---|
| Creation | Dashboard, API provisioning endpoint, IaC templates | Keys created with admin scope by default; no expiry set |
| Storage | Environment variables, .env files, secrets managers, config files | Committed to git; stored in plaintext in config files checked into repos |
| Transmission | HTTP headers, URL parameters, request bodies | Passed as query parameters; logged by CDNs, load balancers, proxies |
| Usage | Server-side services, CI/CD pipelines, frontend JS bundles | Keys embedded in compiled frontend code; overly broad permissions used "just in case" |
| Rotation | Secrets manager rotation policies, manual key management | Keys never rotated; revocation not tested; old keys still active after rotation |
| Revocation | Provider dashboards, API management platforms | Revocation takes effect with delay or not at all; keys cached in downstream systems |
API Key Leakage Detection
GitHub and GitLab Secret Scanning
Source code repositories are the most common leakage vector. Developers accidentally commit .env files, copy configuration snippets with live keys into test files, or hardcode keys when the secrets manager "isn't set up yet." Even if the commit is later deleted, the key remains in git history unless the history is explicitly rewritten.
GitHub's native secret scanning covers approximately 200 provider patterns. For anything not in their list, or for internal API key formats, you need custom regex. Here are regex patterns for the most commonly leaked key formats:
# Stripe — live secret keys (sk_live_) and restricted keys (rk_live_)
sk_live_[0-9a-zA-Z]{24,}
rk_live_[0-9a-zA-Z]{24,}
# AWS — access key IDs (always start with AKIA or ASIA for temporary)
(AKIA|ASIA)[0-9A-Z]{16}
# AWS secret access keys (40-char base64 — best detected alongside key IDs)
(?i)aws(.{0,20})?['"][0-9a-zA-Z/+]{40}['"]
# SendGrid
SG\.[0-9A-Za-z\-_]{22}\.[0-9A-Za-z\-_]{43}
# Twilio account SID + auth token
AC[0-9a-f]{32}
# Twilio auth token (32 hex chars, harder to isolate without context)
(?i)twilio.{0,20}['"][0-9a-f]{32}['"]
# Mailgun
key-[0-9a-zA-Z]{32}
# Slack tokens
xox[baprs]-([0-9a-zA-Z]{10,48})
# Generic high-entropy secrets (catches many API key formats)
(?i)(api_key|api-key|apikey|secret|token|auth|password)['"\s]*[:=]['"\s]*[0-9a-zA-Z\-_]{20,}
# Google API keys
AIza[0-9A-Za-z\-_]{35}
# GitHub personal access tokens (classic)
ghp_[0-9a-zA-Z]{36}
# GitHub fine-grained PATs
github_pat_[0-9a-zA-Z_]{82}
Run these patterns against your repository history, not just the current working tree. A key committed three years ago and later deleted is still in git history and can be extracted in seconds:
# Search entire git history for Stripe keys
git log --all --oneline | cut -d' ' -f1 | xargs -I{} git show {} | grep -E "sk_live_[0-9a-zA-Z]{24,}"
# TruffleHog: scan full git history for high-entropy secrets
trufflehog git file://. --only-verified
# TruffleHog: scan a remote GitHub repository
trufflehog github --repo=https://github.com/org/repo --only-verified
# Gitleaks: scan current directory with default rules
gitleaks detect --source=. --verbose
# Gitleaks: scan git history
gitleaks detect --source=. --log-opts="--all" --verbose
# detect-secrets: baseline scan and report
detect-secrets scan . > .secrets.baseline
detect-secrets audit .secrets.baseline
Testing methodology note: Always test with --only-verified in TruffleHog where possible. Unverified findings include both false positives and genuine keys that happen to be rotated already. Focus investigation effort on verified-active keys first, then review high-confidence unverified findings.
Frontend JavaScript Source Leakage
Not all API keys belong server-side — some providers issue "publishable" or "public" keys intentionally designed to be included in browser-facing code (Stripe's pk_live_ keys, Google Maps embed keys). The distinction matters enormously for testing: a publishable key appearing in JS is expected; a secret key appearing in JS is a critical finding.
Test methodology for frontend key exposure:
# Fetch all JS bundles linked from a page
curl -s https://app.target.com | grep -oE 'src="([^"]+\.js)"' | cut -d'"' -f2
# Download and search a specific JS bundle
curl -s https://app.target.com/static/js/main.abc123.js | \
grep -oE '(sk_live|AKIA|SG\.|xox[baprs]|AIza|ghp_)[0-9A-Za-z\-_]{10,}'
# Search all JS files in a directory (after downloading with wget/httrack)
grep -r --include="*.js" -E \
"(api_key|apiKey|API_KEY|secret|token)['\"\s]*[:=]['\"\s]*[0-9a-zA-Z\-_]{20,}" \
./downloaded-site/
# Look for environment variable names that suggest embedded secrets
grep -r --include="*.js" -E \
"process\.env\.(API_KEY|SECRET|TOKEN|AWS_)" ./src/
Pay attention to React and Vue applications in particular. Build tools like Vite and Create React App will embed any environment variable prefixed with VITE_ or REACT_APP_ directly into the compiled JS bundle. Developers frequently use these prefixes without realising the variable ends up in public client code.
Error Responses and Stack Traces Leaking Keys
When upstream API calls fail, verbose error handlers sometimes include the full request context — including the API key used in the outbound call — in the HTTP response returned to the client.
# Trigger error conditions that might expose internal API calls
# Malformed input to endpoints that proxy to third-party APIs
curl -X POST https://api.target.com/v1/send-email \
-H "Content-Type: application/json" \
-d '{"to": null, "subject": "", "body": ""}'
# Oversized payloads can trigger different error handlers
curl -X POST https://api.target.com/v1/process \
-H "Content-Type: application/json" \
-d "$(python3 -c "import json; print(json.dumps({'data': 'A'*100000}))")"
# Invalid content types can expose framework-level errors
curl -X POST https://api.target.com/v1/webhook \
-H "Content-Type: text/xml" \
-d "<?xml version='1.0'?><data></data>"
# What to look for in responses:
# - "Authorization: Bearer sk_live_..." in error output
# - Axios/Fetch error objects containing the full request config
# - Django/Flask debug pages showing os.environ
# - Node.js error objects with process.env included
Log Files Containing API Key Values
Structured logging frameworks that serialize request objects will capture API keys passed in headers or query parameters. This is especially common in API gateway access logs (AWS API Gateway, Kong, Nginx) and application performance monitoring tools. Test by:
- Reviewing your CloudWatch/Datadog/Splunk log fields for any field containing the raw value of
Authorizationheaders or query parameters namedapi_key,token, orkey - Checking whether your APM agent (New Relic, Datadog APM) is configured to scrub sensitive headers before transmission
- Auditing Nginx access log format strings — the default
$requestvariable captures the full URL including query parameters - Reviewing CDN log exports (Cloudflare, Fastly, Akamai) for API key values appearing in the URL column
# Nginx: check if access logs include query strings
grep -E "log_format|access_log" /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf
# Example of a log format that leaks keys in query params:
# log_format main '$remote_addr - $request ...'
# $request = "GET /api/v1/data?api_key=sk_live_abc123 HTTP/1.1"
# AWS API Gateway: check CloudWatch log format
# Access logs with $context.identity.apiKey will log the key ID (safe)
# but some log configurations use $context.requestId which is fine;
# the risk is logging $context.authorizer.claims or custom request params
API Key Scoping and Least Privilege Testing
Testing Over-Privileged Keys
Many developers create a single API key with admin or full-access permissions and use it everywhere. When that key leaks, the blast radius is total. Scoping testing verifies that keys have only the permissions they actually need.
The testing approach is straightforward: obtain a key that should have restricted access, then attempt operations outside that scope:
# If a key is documented as "read-only", test write operations
# Stripe: attempt to create a charge with a restricted key
curl https://api.stripe.com/v1/charges \
-u rk_live_RESTRICTED_KEY_HERE: \
-d amount=100 \
-d currency=usd \
-d source=tok_visa
# Expected: 403 or "This API key does not have permission to create charges"
# Finding: if the charge succeeds, the key is over-privileged
# Twilio: attempt to delete a phone number with a read-scoped key
curl -X DELETE https://api.twilio.com/2010-04-01/Accounts/ACxxxxxxxx/IncomingPhoneNumbers/PNxxxxxxxx.json \
-u $TWILIO_ACCOUNT_SID:$READ_ONLY_AUTH_TOKEN
# Expected: 403 Forbidden
# Finding: if deletion succeeds, the key has write access it shouldn't have
# SendGrid: attempt to send email with a key scoped only to "Mail Send"
# then attempt to read contact lists (out of scope)
curl -X GET https://api.sendgrid.com/v3/marketing/lists \
-H "Authorization: Bearer MAIL_SEND_SCOPED_KEY"
# Expected: 403
# Finding: if contact list data is returned, the key scope is too broad
# AWS: test IAM key permissions beyond what the role should allow
aws s3 ls --profile restricted-key # Should work if S3 read is granted
aws s3 rb s3://production-bucket --profile restricted-key # Should fail
aws ec2 describe-instances --profile restricted-key # Should fail if not EC2
aws iam list-users --profile restricted-key # Should fail if not IAM
Missing IP Allowlist Restrictions
High-privilege API keys should be restricted to originate only from known IP ranges. An API key with no IP restriction is usable from any network — meaning a leaked key can be exploited immediately from anywhere in the world. Test this by:
# Verify whether a key rejects requests from unexpected source IPs
# (Test from a VPN exit node or separate network not in your allowlist)
# Using a cloud function or temporary VM to test from an external IP:
curl -H "X-API-Key: $API_KEY" https://api.target.com/v1/admin/users
# If no IP restriction is configured, the request will succeed from any origin.
# A well-configured key should return 403 with a message like:
# {"error": "API key not authorized from this IP address"}
Document which keys have IP restrictions and which do not. Any key with write or admin access that lacks IP allowlisting is a significant finding.
Common Platform Misconfigurations
| Platform | Common Misconfiguration | How to Test |
|---|---|---|
| AWS IAM | * wildcard in Action or Resource; no MFA condition on sensitive actions; access keys on root account |
Run aws iam get-policy-version and review for wildcards; aws iam generate-credential-report to check root key usage |
| Stripe | Using secret keys (sk_live_) instead of restricted keys for specific operations; publishable keys used to query customer data | Review key type in Stripe dashboard; test restricted key permissions against their documented scope |
| Twilio | Main auth token used instead of API Key + API Secret; no IP Access Control List on the subaccount | Check if API Keys are used instead of main auth token; verify IP ACL configuration in console |
| SendGrid | Full Access key used instead of a restricted key scoped to required permissions | In SendGrid Settings > API Keys, verify each key shows "Restricted Access" with minimal permissions listed |
| GitHub | Classic PATs with repo scope (grants full read/write to all repos) instead of fine-grained tokens scoped to specific repositories and operations |
Audit PATs at github.com/settings/tokens; verify fine-grained tokens are used with minimal permissions |
API Key Rotation Testing
Keys That Never Expire
Many API providers allow creating keys with no expiry. From an operational standpoint this is convenient — you don't need to update secrets on a schedule. From a security standpoint it means a key that leaks today continues to be valid indefinitely unless someone notices and revokes it. Test expiry policy by:
# Check key metadata for expiry information
# AWS: when does this access key expire?
aws iam list-access-keys --user-name service-account-name
# Response includes CreateDate but NOT expiry — AWS IAM keys don't expire by default
# Check if a password policy (or SCP) enforces rotation
# GitHub: list PATs and their expiry
# Via API (requires admin token):
curl -H "Authorization: token $ADMIN_TOKEN" \
https://api.github.com/orgs/$ORG/installations
# AWS: find access keys older than 90 days (sign of no rotation policy)
aws iam generate-credential-report
aws iam get-credential-report --output text --query 'Content' | \
base64 -d | \
awk -F, 'NR>1 && $9 != "N/A" {print $1, $9}'
# Column 9 = access_key_1_last_rotated
# Keys with dates older than 90 days have not been rotated recently
Keys Hardcoded in Infrastructure
IaC templates and environment files are the second most common leakage vector after git history. Terraform state files, CloudFormation templates, Kubernetes secrets stored as base64, and Docker Compose environment files all routinely contain live credentials:
# Scan Terraform files for hardcoded secrets
grep -r --include="*.tf" --include="*.tfvars" -E \
"(password|secret|api_key|token|access_key)\s*=\s*\"[^$\{][^\"]{8,}\"" .
# Terraform state files contain every resource attribute in plaintext
# This is a critical finding if state is stored without encryption
grep -r --include="*.tfstate" -E \
"(password|secret_key|api_key|token)[\"']?\s*:\s*\"[^\"]{8,}\"" .
# Kubernetes secrets are base64-encoded, not encrypted
kubectl get secrets --all-namespaces -o json | \
jq '.items[].data | to_entries[] | {key: .key, value: (.value | @base64d)}' 2>/dev/null
# Docker Compose environment files
grep -r --include="docker-compose*.yml" --include=".env*" -E \
"(API_KEY|SECRET|TOKEN|PASSWORD)=[^\$][^=]{6,}" .
# Ansible vaults that aren't actually vaulted
grep -r --include="*.yml" --include="*.yaml" -v "ANSIBLE_VAULT" -E \
"(api_key|secret|password):\s+['\"]?[0-9a-zA-Z\-_]{16,}" ./ansible/
Testing Revocation: Do Old Keys Actually Stop Working?
This is one of the most commonly overlooked tests. Organisations rotate keys by generating a new key and updating their systems, but they don't always verify that the old key is actually revoked. They also don't test whether revocation takes effect immediately or with a delay during which the old key remains valid.
# Rotation test procedure:
# 1. Record the current key value
OLD_KEY="sk_live_abc123xyz"
# 2. Generate a new key via the provider's dashboard or API
# 3. Update your application to use the new key
# 4. Revoke the old key via the provider's dashboard or API
# 5. Immediately test whether the old key still works:
curl -u "$OLD_KEY:" https://api.stripe.com/v1/charges?limit=1
# Expected: 401 Unauthorized
# Finding: if this returns data, revocation is not immediate
# For AWS: deactivate and then test
aws iam update-access-key --access-key-id AKIA... --status Inactive
curl -X GET "https://s3.amazonaws.com/my-bucket" \
--aws-sigv4 "aws:amz:us-east-1:s3" \
--user "$OLD_ACCESS_KEY:$OLD_SECRET_KEY"
# Test with 0s, 5s, 30s, 60s delay to characterise revocation lag
# For internal API key systems: test whether your revocation endpoint
# invalidates cached tokens in downstream services
curl -H "X-API-Key: $REVOKED_KEY" https://api.internal.company.com/v1/data
Secrets Scanning in CI/CD Pipelines
The best time to catch a leaked key is before it reaches the remote — during the pre-commit hook or in the CI pipeline before the PR is merged. Configure scanning at both layers:
# Pre-commit hook using git-secrets
git secrets --install
git secrets --register-aws # Adds AWS key patterns
# Pre-commit hook using gitleaks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# CI pipeline: GitHub Actions with TruffleHog
# .github/workflows/secrets-scan.yml
name: Secret Scanning
on: [push, pull_request]
jobs:
trufflehog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
# GitLab CI equivalent
secret_detection:
stage: test
image: trufflesecurity/trufflehog:latest
script:
- trufflehog git file://. --only-verified --fail
Key Transmission Security
Keys in URL Query Parameters
Passing API keys as URL query parameters is the most dangerous transmission pattern. URLs are logged by virtually every component in the request path: client-side browser history, CDN access logs, load balancer logs, reverse proxy access logs, server application logs, and third-party analytics tools that read the Referer header. A key that appears in a URL may be stored in a dozen different log systems simultaneously.
# Test whether your application accepts keys as query parameters
# (even if not documented — some APIs accept both header and query param)
curl "https://api.target.com/v1/data?api_key=sk_live_abc123"
curl "https://api.target.com/v1/data?key=sk_live_abc123"
curl "https://api.target.com/v1/data?token=sk_live_abc123"
curl "https://api.target.com/v1/data?access_token=sk_live_abc123"
# In Burp Suite: search responses for "api_key" in request history
# Proxy > HTTP history > filter by "api_key" in request URL
# Check your Nginx access log format for captured query strings:
tail -100 /var/log/nginx/access.log | grep -oE '\?[^"]*api_key=[^&"]*'
Correct Header-Based Transmission
API keys should be transmitted in HTTP headers, not URLs or request bodies. The two standard patterns are:
# Pattern 1: Bearer token (OAuth 2.0 compatible, most common)
Authorization: Bearer sk_live_abc123
# Pattern 2: Custom API key header (used by many SaaS APIs)
X-API-Key: sk_live_abc123
# Test whether your API rejects keys in query parameters
# while accepting them only in headers:
curl "https://api.target.com/v1/data?api_key=sk_live_abc123"
# Should return 401 if query param transmission is disabled
curl -H "Authorization: Bearer sk_live_abc123" https://api.target.com/v1/data
# Should return 200
# Verify TLS is enforced — keys in headers over plaintext HTTP are still exposed
curl http://api.target.com/v1/data -H "Authorization: Bearer sk_live_abc123"
# Should return 301/302 redirect to HTTPS, or refuse the connection entirely
# Finding: if this returns data over plaintext HTTP, the key was transmitted in cleartext
Keys in Request Bodies
Some older or internal APIs accept credentials in POST request bodies (application/x-www-form-urlencoded or JSON). This is safer than query parameters — request bodies are not logged by most CDNs or proxies by default — but still worse than headers. Request bodies are logged by application-level middleware, APM agents, and WAFs that perform deep inspection.
# Test if an API accepts credentials in the request body
curl -X POST https://api.target.com/v1/query \
-d "api_key=sk_live_abc123&query=users"
# Also test JSON body
curl -X POST https://api.target.com/v1/query \
-H "Content-Type: application/json" \
-d '{"api_key": "sk_live_abc123", "query": "users"}'
# Neither pattern should be accepted if the API is correctly designed
# Both should return 401 unless the API explicitly documents body-based auth
Practical Testing Workflow
Manual Review Checklist
Use this checklist when assessing an application's API key security posture:
- Leakage: Run TruffleHog and Gitleaks against full git history. Review compiled JS bundles for embedded secrets. Trigger error conditions on endpoints that proxy external APIs and inspect responses for key values in error output.
- Scoping: Obtain a copy of each key type used. Attempt operations outside the documented scope (writes with a read key, admin operations with a user key). Check whether IP allowlists are configured on high-privilege keys.
- Rotation: Check key creation dates — any key older than 90 days that hasn't been rotated is a finding. Verify that your CI/CD pipeline has pre-commit hooks or pipeline steps that fail on detected secrets.
- Revocation: Rotate a test key and immediately verify that the old key returns 401. Document the revocation lag time.
- Transmission: Confirm keys are sent in headers, not query parameters or request bodies. Verify TLS is enforced on all endpoints accepting API keys. Review access log formats to confirm key values are not captured in logs.
- Storage: Verify no keys are stored in code, IaC templates, or environment files committed to version control. Confirm production keys are managed via a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) with access logging enabled.
Automated Tool Summary
# TruffleHog — verified secret detection with provider validation
# Best for: git history scanning, GitHub org scanning
trufflehog git file://. --only-verified
trufflehog github --org=myorg --only-verified
# Gitleaks — fast regex-based scanning with SARIF output for CI integration
# Best for: pre-commit hooks, CI pipeline gates
gitleaks detect --source=. --report-format=sarif --report-path=results.sarif
# detect-secrets — baseline management for gradual rollout
# Best for: brownfield repos where you want to track new additions
detect-secrets scan --baseline .secrets.baseline
detect-secrets audit .secrets.baseline
# Semgrep — semantic code analysis, catches keys in application logic
# Best for: finding keys assigned to variables, passed to logging functions
semgrep --config=p/secrets .
# Burp Suite — runtime interception for testing key transmission
# Best for: verifying header vs. query param usage, finding keys in responses
# Pro tip: use Burp's "Search" in HTTP history with regex:
# (sk_live|AKIA|SG\.|xox[baprs])[0-9A-Za-z\-_]{10,}
Burp Suite: Finding Keys in HTTP Traffic
During authenticated testing, proxy all traffic through Burp Suite and use the following workflow to identify key-related issues:
# Burp Suite workflow for API key testing:
# 1. Capture a baseline of authenticated requests
# Proxy > HTTP history > filter to target scope
# 2. Search request history for keys in URLs
# Proxy > HTTP history > right-click > "Search" > enable "regex"
# Pattern: [?&](api_key|key|token|access_token)=[^&\s]{10,}
# 3. Search responses for key values leaking in error output
# Burp > Search > "Search in responses"
# Pattern: (sk_live_|AKIA|SG\.|rk_live_)[0-9A-Za-z\-_]{10,}
# 4. Check Intruder for key parameter injection
# If an endpoint accepts api_key as a query param, mark it as injection point
# Test whether removing the header but adding the query param still works
# 5. Use Burp Collaborator to test blind key leakage
# Inject a Collaborator URL as a redirect target on endpoints that
# make outbound requests — check if the API key appears in Collaborator logs
Catch Exposed API Keys Before Attackers Do
Ironimo scans your web application's HTTP traffic for exposed API keys and credentials on every deployment, before attackers find them first. Automated key pattern detection, transmission security checks, and response scanning — the same techniques security engineers use, running continuously against your production API surface.
Start free scan