The Verification Gap
Most API key workflows have a glaring hole:
- Generation — You create keys. Maybe with a script. Maybe manually. Maybe the cloud provider generates them. This part works.
- Storage — Keys end up in .env files, 1Password, AWS Secrets Manager, or a sticky note. Scattered.
- Usage — Applications read the key from environment variables and attach it to requests. Fine.
- Verification — ???
When your API receives a request with key ak_xYz123abc, what happens?
- If you store keys in plaintext, you're one config leak away from a breach.
- If you store only hashes but have no CLI tool to verify, you're writing ad-hoc scripts every time.
- If you skip verification entirely, revoked and expired keys keep working until someone manually removes them.
APIAuth's verify command closes this gap. It takes an incoming key, checks it against the SHA-256 hashes in your encrypted keystore, and returns the status: valid, revoked, or expired.
Quick Start: Verify Your First Key
Step 1: Generate a Key
# Install APIAuth
pip install apiauth
# Generate an API key
apiauth generate api-key \
--name "Gateway Key" \
--service "api-gateway" \
--expiry-days 90
# Output:
# ✓ Generated API key: ak_a1b2c3d4e5f6...
# ID: k7f2a9c1
# Name: Gateway Key
# Service: api-gateway
# Expires: 2026-08-25
#
# ⚠ Save this key now — it won't be shown again.
APIAuth displays the plaintext key once. After that, only the SHA-256 hash is stored in the encrypted keystore. This is by design — plaintext keys are never persisted.
Step 2: Verify the Key
# Verify a key at runtime
apiauth verify ak_a1b2c3d4e5f6...
# Output if valid:
# ✓ Key k7f2a9c1 is VALID
# Name: Gateway Key
# Service: api-gateway
# Version: 1
# Rate limit: 100 req/s
# Output if revoked:
# ✗ Key k7f2a9c1 is REVOKED
# Output if expired:
# ✗ Key k7f2a9c1 is EXPIRED
# Output if key doesn't exist:
# ✗ Key is INVALID
# Key not found in keystore
Step 3: JSON Output for Automation
# Machine-readable verification
apiauth verify ak_a1b2c3d4e5f6... --json-output
# Output:
{
"id": "k7f2a9c1",
"name": "Gateway Key",
"service": "api-gateway",
"status": "valid",
"version": 1,
"rate_limit": 100,
"expires_at": "2026-08-25T00:00:00Z"
}
The JSON output makes it trivial to integrate verification into any middleware, Lambda authorizer, or API gateway hook.
How Verify Works Under the Hood
When you run apiauth verify:
- The CLI hashes the input key with SHA-256.
- It opens the encrypted keystore (AES-256-GCM, master key in
~/.apiauth/master.key). - It compares the hash against every stored key entry.
- If a match is found, it checks the
revokedflag and theexpires_attimestamp. - It returns the status: valid, revoked, expired, or not found.
Zero plaintext storage. The keystore never stores the raw key value. Verification is always hash-based — the same pattern password systems have used for decades, now applied to API keys.
Importing Existing Keys
If you already have API keys — from AWS, Stripe, SendGrid, or any other service — you can bring them into APIAuth's encrypted keystore with import:
# Import an existing API key
apiauth import ak_existing_stripe_key_value \
--name "Stripe Live Key" \
--service "stripe" \
--expiry-days 365
# Output:
# ✓ Imported API key into keystore
# ID: k9b3d7e2
# Name: Stripe Live Key
# Service: stripe
# Expires: 2027-05-27
#
# ⚠ The plaintext key is NOT stored. Use 'verify' to check incoming keys.
Import hashes the key immediately. The plaintext is discarded after import — you can still verify incoming keys against the hash, but the raw value is gone from the keystore.
Before importing, save the key value somewhere safe. After import, APIAuth only stores the hash. If you need the plaintext value later (e.g., to send in an Authorization header), keep it in your secrets manager or CI environment. APIAuth manages the verification lifecycle, not the delivery lifecycle.
Four Verification Patterns for Production APIs
Pattern 1: Lambda Authorizer (AWS API Gateway)
Verify incoming API keys in a Lambda authorizer. APIAuth runs in milliseconds — no cold-start penalty worth worrying about:
# lambda_authorizer.py
import subprocess, json
def lambda_handler(event, context):
# Extract key from Authorization header
auth_header = event.get("headers", {}).get("authorization", "")
api_key = auth_header.replace("Bearer ", "").strip()
if not api_key:
return {"statusCode": 401, "body": "Missing API key"}
# Verify with APIAuth
result = subprocess.run(
["apiauth", "verify", api_key, "--json-output"],
capture_output=True, text=True
)
if result.returncode != 0:
return {"statusCode": 401, "body": "Invalid API key"}
data = json.loads(result.stdout)
if data.get("status") != "valid":
return {"statusCode": 401, "body": f"Key is {data.get('status')}"}
# Key is valid — allow the request
return {
"principalId": data["id"],
"policyDocument": {
"Version": "2012-10-17",
"Statement": [{
"Action": "execute-api:Invoke",
"Effect": "Allow",
"Resource": event["routeArn"]
}]
},
"context": {
"keyName": data.get("name", ""),
"keyService": data.get("service", "")
}
}
Pattern 2: Express.js Middleware
Use APIAuth as a subprocess in Express middleware:
// middleware/apiauth.js
const { execSync } = require('child_process');
function verifyApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return res.status(401).json({ error: 'Missing API key' });
try {
const result = execSync(
`apiauth verify ${apiKey} --json-output`,
{ encoding: 'utf-8', timeout: 5000 }
);
const data = JSON.parse(result);
if (data.status !== 'valid') {
return res.status(401).json({ error: `Key is ${data.status}` });
}
// Attach key metadata to request
req.apiKey = data;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid API key' });
}
}
// Usage
app.use('/api', verifyApiKey);
Pattern 3: CI/CD Gate — Block Deploys with Expired Keys
Before deploying, check that none of your production keys are expired or revoked:
# .github/workflows/key-check.yml
name: API Key Health Check
on: [pull_request]
jobs:
key-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install apiauth
# Audit the keystore — exit 1 if any expired keys
- name: Check for expired/revoked keys
run: apiauth audit --exit-on-expired
# Verify specific production keys
- name: Verify gateway key
run: |
KEY="${{ secrets.API_GATEWAY_KEY }}"
apiauth verify "$KEY" --json-output > /tmp/result.json
STATUS=$(python3 -c "import json; print(json.load(open('/tmp/result.json')).get('status'))")
if [ "$STATUS" != "valid" ]; then
echo "::error::Gateway key is $STATUS"
exit 1
fi
Pattern 4: Batch Import + Verify for Key Migration
Migrating from a flat-file key system? Import all keys, then verify they're accessible:
#!/bin/bash
# migrate_keys.sh — Import keys from legacy .env file
while IFS='=' read -r name value; do
# Skip comments and empty lines
[[ "$name" =~ ^#.*$ ]] && continue
[[ -z "$value" ]] && continue
echo "Importing $name..."
apiauth import "$value" \
--name "$name" \
--service "legacy-migration" \
--expiry-days 365
# Immediately verify the import worked
RESULT=$(apiauth verify "$value" --json-output 2>&1)
STATUS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null)
if [ "$STATUS" = "valid" ]; then
echo " ✓ $name imported and verified"
else
echo " ✗ $name import failed (status: $STATUS)"
fi
done < legacy_keys.env
The Verify vs. Lookup Decision
When should you use apiauth verify vs. a simple key lookup?
| Approach | How It Works | Revocation Check | Expiry Check | Security |
|---|---|---|---|---|
| Plaintext allowlist | Compare key to list of known values | Manual — must remove from list | Manual — must check dates | Weak — keys stored in plaintext |
| Database lookup | Query key table by value | Check revoked column | Check expires_at column | Medium — depends on DB security |
apiauth verify |
Hash input, compare to encrypted keystore | Automatic — checked on every verify | Automatic — checked on every verify | Strong — SHA-256 + AES-256-GCM |
The key difference: verify checks revocation and expiry every time. You don't need to remember to update an allowlist or add a date check — the CLI handles it.
Verification Response States
Every apiauth verify call returns one of four states:
| Status | Meaning | Action |
|---|---|---|
valid |
Key hash matches, not revoked, not expired | Allow the request |
revoked |
Key was explicitly revoked with apiauth revoke |
Deny — key has been compromised or decommissioned |
expired |
Key's expires_at timestamp is in the past |
Deny — key needs rotation |
| Not found | No matching hash in the keystore | Deny — unknown key |
Combining Verify with Rotate
When verification returns expired, the natural next step is rotation:
# Verify returns expired
apiauth verify ak_old_key_value
# ✗ Key k7f2a9c1 is EXPIRED
# Rotate the key
apiauth rotate k7f2a9c1
# ✓ Rotated key k7f2a9c1
# New version: 2
# New key: ak_new_rotated_value...
#
# ⚠ Save this key now — it won't be shown again.
# Verify the new key
apiauth verify ak_new_rotated_value
# ✓ Key k7f2a9c1 is VALID
# Version: 2
Rotation creates a new key value and increments the version. The previous value is hashed out — it can no longer be verified as valid. This is the zero-downtime pattern covered in our key rotation tutorial.
Import + Verify: The Security Model
APIAuth's import-then-verify model follows a deliberate security principle:
Separation of concerns: APIAuth manages the verification lifecycle (is this key valid?). Your secrets manager or CI platform manages the delivery lifecycle (where does the application get the key at runtime?). These are two different problems, and conflating them leads to either storing plaintext in the verification layer or building fragile delivery into the keystore.
Practically, this means:
- Generate with APIAuth — get the plaintext value once.
- Store the plaintext in your CI secrets, AWS Secrets Manager, or .env file.
- Import the key into APIAuth — the hash goes into the encrypted keystore.
- Verify incoming requests with APIAuth — hash-based, no plaintext needed.
- Rotate with APIAuth — old value hashed out, new value generated.
- Revoke with APIAuth — instant, verification returns
revoked.
Listing and Searching Keys
Before verifying, you may want to see what's in the keystore:
# List all keys with status
apiauth list
# Output:
# ┌──────────┬──────────────┬──────────────┬─────────┬────────────┐
# │ ID │ Name │ Service │ Status │ Expires │
# ├──────────┼──────────────┼──────────────┼─────────┼────────────┤
# │ k7f2a9c1 │ Gateway Key │ api-gateway │ Valid │ 2026-08-25 │
# │ k9b3d7e2 │ Stripe Key │ stripe │ Valid │ 2027-05-27 │
# │ k3c8f1a4 │ Old Key │ payments │ Expired │ 2026-01-15 │
# │ k5d2e9b7 │ Leaked Key │ auth │ Revoked │ — │
# └──────────┴──────────────┴──────────────┴─────────┴────────────┘
# Filter by service
apiauth list --service "api-gateway"
# JSON output for scripting
apiauth list --json-output
Keystore Statistics
# Quick overview
apiauth stats
# Output:
# Total keys: 12
# Active: 8
# Expired: 3
# Revoked: 1
# Services: api-gateway, stripe, auth, payments
Security Architecture
APIAuth's keystore uses layered encryption:
- Master key — stored in
~/.apiauth/master.key, generated on first use, never leaves the machine. - Keystore file —
~/.apiauth/keystore.enc, encrypted with AES-256-GCM using the master key. - Key values — stored as SHA-256 hashes. Even if the keystore is decrypted, you only see hashes.
- Plaintext — only displayed once at generation time. Never written to disk.
Protect your master key. If ~/.apiauth/master.key is compromised, an attacker can decrypt the keystore and read the key metadata (names, services, expiry dates). They cannot recover the plaintext key values (those are SHA-256 hashes), but the metadata alone may reveal which services you use. Treat the master key like any other secret — restrict file permissions, don't commit it, and don't copy it between machines.
Real-World Scenario: Multi-Service API Gateway
You run an API gateway that authenticates requests for five backend services. Each service has its own key. Here's the complete setup:
# Generate keys for each service
apiauth generate api-key -n "Auth Service" -s auth --expiry-days 90 --rate-limit 500
apiauth generate api-key -n "Payments" -s payments --expiry-days 90 --rate-limit 100
apiauth generate api-key -n "Notifications" -s notifications --expiry-days 180 --rate-limit 1000
apiauth generate api-key -n "Search" -s search --expiry-days 90 --rate-limit 200
apiauth generate api-key -n "Analytics" -s analytics --expiry-days 365 --rate-limit 50
# List all keys
apiauth list
# ┌──────────┬──────────────────┬───────────────┬─────────┬────────────┬────────────┐
# │ ID │ Name │ Service │ Status │ Rate Limit │ Expires │
# ├──────────┼──────────────────┼───────────────┼─────────┼────────────┼────────────┤
# │ k7f2a9c1 │ Auth Service │ auth │ Valid │ 500 req/s │ 2026-08-25 │
# │ k9b3d7e2 │ Payments │ payments │ Valid │ 100 req/s │ 2026-08-25 │
# │ k1a4c8f3 │ Notifications │ notifications │ Valid │ 1000 req/s │ 2026-11-23 │
# │ k3c5e7d9 │ Search │ search │ Valid │ 200 req/s │ 2026-08-25 │
# │ k5d7f9b1 │ Analytics │ analytics │ Valid │ 50 req/s │ 2027-05-27 │
# └──────────┴──────────────────┴───────────────┴─────────┴────────────┴────────────┘
# Verify an incoming request
apiauth verify ak_incoming_key_value --json-output
# Export active keys for the gateway
apiauth export --format env --service auth
# export APIAUTH_AUTH_SERVICE_ID=k7f2a9c1
# export APIAUTH_AUTH_SERVICE_SERVICE=auth
# export APIAUTH_AUTH_SERVICE_CREATED=2026-05-27
# export APIAUTH_AUTH_SERVICE_EXPIRES=2026-08-25
# Audit before deployment
apiauth audit --exit-on-expired
# Checks all keys, exits 1 if any are expired
Install APIAuth
# pip
pip install apiauth
# Generate your first key
apiauth generate api-key --name "My First Key" --service "test"
# Verify it
apiauth verify ak_your_key_value
Star APIAuth on GitHub
Related Reading
- Audit Your API Credentials: Catch Expired and Revoked Keys — keystore health audit
- Zero-Downtime API Key Rotation in CI/CD — automated rotation workflow
- Envault + APIAuth: Rotate Keys Across Environments — cross-tool rotation
- Review Infrastructure Changes Before Apply — DeployDiff preview workflow