---
name: forge-secrets
description: Secret handling discipline. Env loading with fail-fast validation, log redaction at source, error-to-client hygiene, weak-hash detection, rotation runbooks, hashed-at-rest API tokens, never-commit guards. Contains startup-validator code, redaction config, secret-scan setup. Use whenever code holds an API key, password, signing key, OAuth token, or session secret.
license: MIT
---

# forge-secrets

You are writing code that handles API keys, database passwords, signing keys, OAuth tokens, or anything else that grants access. Default agent output leaks secrets in three predictable ways: into git history, into logs, and into error messages bubbled to the client. This skill exists to stop all three.

The cost of a leaked secret is asymmetric. Five minutes of vigilance prevents a credential rotation that costs hours.

## Quick reference (the things you must never ship)

1. A hardcoded `sk_live_...`, `ghp_...`, `AKIA...`, `xoxb-...` string anywhere in the codebase.
2. `process.env.API_KEY || 'sk_test_default'` - default value for a secret.
3. `crypto.createHash('sha256')` (or md5/sha1) used on a password.
4. JWT stored in `localStorage` for browser clients.
5. `accept_alg: ['none']` (or no `algorithm` check) on JWT verification.
6. Secret value echoed in a log line, even at debug level.
7. Stack trace returned to a client in a 5xx response.
8. `.env` file checked into git (even with the values placeholder).
9. Bearer token sent in a query string.
10. Recovery code or reset token stored unhashed in the database.

## Hard rules

### Loading

**1. Secrets come from the environment or a secrets manager, never from source code.** No exceptions.

**2. `.env` files are local-only.** Listed in `.gitignore` before created. Never committed. A `.env.example` with placeholder values lives in git.

```
# .gitignore
.env
.env.local
.env.*.local

# .env.example (in git)
DATABASE_URL=postgres://localhost:5432/dev
STRIPE_API_KEY=sk_test_REPLACE_ME
SESSION_SECRET=
```

**3. Validate required secrets at startup, not at first use.** Missing `STRIPE_API_KEY` should crash the process on boot, not at the first checkout attempt.

```ts
// reference: forge-validation style
const required = ['DATABASE_URL', 'STRIPE_API_KEY', 'SESSION_SECRET'] as const;
const missing = required.filter((k) => !process.env[k]);
if (missing.length > 0) {
  console.error(`[fatal] missing env: ${missing.join(', ')}`);
  process.exit(1);
}
```

Better: combined with zod (see [`forge-validation`](../../backend/forge-validation/SKILL.md)):

```ts
import { z } from "zod";

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_API_KEY: z.string().startsWith("sk_"),
  SESSION_SECRET: z.string().min(32),
});

export const env = (() => {
  const parsed = EnvSchema.safeParse(process.env);
  if (!parsed.success) {
    console.error("[fatal] invalid env:", parsed.error.format());
    process.exit(1);
  }
  return parsed.data;
})();
```

**4. No defaults for secrets.** A line like `process.env.API_KEY || 'sk_test_dev'` ships dev creds to production the moment env loading fails. Either the env var is set, or the process refuses to start.

### Redaction in logs

**5. Every log statement that could include a secret is filtered.** Maintain a redaction list:

```
password, passwd, secret, token, api_key, apiKey, auth, authorization,
cookie, set-cookie, private_key, privateKey, refresh_token, access_token,
ssn, credit_card, card_number, cvv
```

Apply via your logger's redaction config (see [`forge-logging`](../../backend/forge-logging/SKILL.md) rule 11).

**6. Error objects are not safe to log raw.** A library that includes the full request (with `Authorization` header) in its error object will leak the bearer token. Strip headers before logging.

```ts
// BAD
logger.error({ err }, "request failed");
// the http library may have attached { request: { headers: { authorization: "Bearer ..." } } } to err

// GOOD
logger.error({ err, status: err.response?.status, code: err.code }, "request failed");
```

**7. Sample the request body, never log it full.** Especially for auth, payment, document upload paths.

### Errors to clients

**8. Production responses never include the original error message verbatim.** Map server errors to client-safe codes + `request_id`. Log full detail server-side.

```ts
// BAD
res.status(500).json({ error: err.message, stack: err.stack });

// GOOD (canonical error shape, forge-api-design rule 7)
logger.error({ err, requestId: req.id }, "internal_error");
res.status(500).json({
  error: { code: "internal_error", message: "Something went wrong.", request_id: req.id },
});
```

**9. No stack traces in production responses.** Stack traces leak file paths, dependency versions, sometimes inline secrets.

### Storage

**10. Passwords hashed with argon2id (preferred) or bcrypt cost 12+.** Never SHA-256, never MD5, never plain.

```ts
import * as argon2 from "argon2";

// hash on signup / password change
const hash = await argon2.hash(password, { type: argon2.argon2id });

// verify on login
const ok = await argon2.verify(hash, candidate);
```

If you see `crypto.createHash('sha256')` near a password, stop.

**11. Symmetric encryption uses AEAD.** AES-GCM or ChaCha20-Poly1305. Never AES-CBC without a separate MAC. Never ECB.

```ts
import { createCipheriv, randomBytes } from "node:crypto";

function encrypt(plaintext: string, key: Buffer): { ciphertext: Buffer; iv: Buffer; tag: Buffer } {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return { ciphertext, iv, tag };
}
```

**12. Encryption keys are rotated, not eternal.** Document the rotation interval. Code that decrypts accepts multiple key versions for zero-downtime rotation.

**13. API tokens stored hashed at rest.**

```ts
// when issuing
const token = `tok_${randomBytes(24).toString("base64url")}`;
const hash  = createHash("sha256").update(token).digest("hex");
await db.api_keys.insert({ user_id, hash, prefix: token.slice(0, 12), created_at: new Date() });
return token;  // shown to the user ONCE

// when verifying
const incoming = req.header("authorization")?.slice(7);
if (!incoming) return unauthorized();
const hash = createHash("sha256").update(incoming).digest("hex");
const key = await db.api_keys.findOne({ hash });
if (!key) return unauthorized();
```

The original token shown to the user once at creation, never again.

### Transport

**14. HTTPS only. HSTS enabled on web surfaces.** No exceptions.

**15. Bearer tokens in `Authorization: Bearer ...`, never in query strings.** Query strings leak into logs, browser history, referer headers.

**16. Cookies that carry session IDs are `HttpOnly`, `Secure`, `SameSite=Lax` (or `Strict`).** Always all four.

```ts
res.cookie("session", id, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 7 * 24 * 60 * 60 * 1000,
});
```

### CI and Git

**17. Pre-commit hook scans for secrets.** Use `gitleaks`, `trufflehog`, or `git-secrets`.

```yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
```

**18. CI also scans.** Belt and suspenders. Pre-commit protects developers who installed it; CI protects everyone.

**19. If a secret is committed, it is compromised.** Rotate before anything else. `git filter-repo` removes history but does not retroactively secure - assume bots scraped the public repo within minutes.

### Rotation

**20. Every secret has a documented rotation procedure.** Five-line runbook entry: how to generate, how to deploy, how to verify, how long the old value remains valid.

**21. Application code accepts current + previous value during rotation.** Grace period where both work prevents an outage. Drop the old after one deploy cycle.

```ts
// reference: dual-key check
function verifyWebhookSignature(body: string, sig: string): boolean {
  const keys = [env.WEBHOOK_SECRET_CURRENT, env.WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
  return keys.some((k) => {
    const expected = createHmac("sha256", k).update(body).digest("hex");
    return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  });
}
```

## Common AI-output patterns to reject

| Pattern | Why dangerous | Fix |
| --- | --- | --- |
| `const KEY = "sk_live_..."` hardcoded | Permanent leak | env + startup validator |
| `process.env.X || 'default'` for secrets | Dev creds in prod silently | Fail fast on missing |
| `crypto.createHash('sha256')` on password | Crackable in seconds | argon2id or bcrypt cost 12+ |
| `localStorage.setItem('token', jwt)` | XSS-readable | HttpOnly cookie |
| JWT decode without verify | Anyone forges tokens | `jwt.verify` with explicit `algorithms: ['RS256']` |
| `alg: 'none'` accepted in JWT | The #1 JWT vuln | Reject explicitly |
| Reset token stored plain | DB leak = account takeover | Store `sha256(token)` |
| `Bearer ${token}` in URL | Logged + cached + referered | `Authorization` header |
| `console.log(req.headers)` | Leaks Authorization | Strip + logger redaction |
| `err.stack` in client response | Reveals internals | Sanitize to `{code, request_id}` |

## Worked example: complete auth-adjacent setup

```ts
// src/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  SESSION_SECRET: z.string().min(32),                   // forge-secrets rule 4
  STRIPE_API_KEY: z.string().startsWith("sk_"),
  WEBHOOK_SECRET_CURRENT: z.string().min(32),
  WEBHOOK_SECRET_PREVIOUS: z.string().min(32).optional(),
});

export const env = (() => {
  const parsed = EnvSchema.safeParse(process.env);
  if (!parsed.success) {
    console.error("[fatal] env:", parsed.error.format());
    process.exit(1);
  }
  return parsed.data;
})();
```

```ts
// src/logger.ts (excerpt)
const REDACT_KEYS = ["password", "secret", "token", "api_key", "apiKey",
                     "authorization", "cookie", "set-cookie", "session_secret"];

export const logger = pino({
  redact: {
    paths: REDACT_KEYS.flatMap((k) => [k, `*.${k}`, `req.headers.${k}`]),
    censor: "[REDACTED]",
  },
});
```

```ts
// src/password.ts
import * as argon2 from "argon2";

export async function hashPassword(plain: string): Promise<string> {
  return argon2.hash(plain, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, candidate: string): Promise<boolean> {
  return argon2.verify(hash, candidate);
}
```

```ts
// src/api_keys.ts
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";

export function issueToken(): { token: string; hash: string; prefix: string } {
  const raw = `tok_${randomBytes(24).toString("base64url")}`;
  const hash = createHash("sha256").update(raw).digest("hex");
  return { token: raw, hash, prefix: raw.slice(0, 12) };
}

export function verifyToken(incoming: string, stored: string): boolean {
  const incomingHash = createHash("sha256").update(incoming).digest("hex");
  return timingSafeEqual(Buffer.from(incomingHash, "hex"), Buffer.from(stored, "hex"));
}
```

What this demonstrates: startup validation; redaction at the logger; argon2id for passwords; tokens hashed at rest; constant-time comparison; dual-key webhook verification for zero-downtime rotation.

## Workflow

When you touch secrets:

1. **Identify the secret.** Where it comes from, where it goes, who needs it.
2. **Pick the loading mechanism.** Local `.env` (dev), platform env vars (prod), or secrets manager (Vault, AWS Secrets Manager, Doppler).
3. **Add to `.env.example` with a placeholder.** Document what it is and where to get it.
4. **Add startup validation.** Process exits if missing.
5. **Configure logger redaction.**
6. **Run gitleaks locally before commit.** `gitleaks detect --source .` or pre-commit hook.
7. **Document the rotation procedure.** Even one line.

## Verification

```bash
bash skills/security/forge-secrets/verify/check_secrets.sh path/to/file
```

Flags: hardcoded high-entropy secret patterns, weak password hashing, default fallback for env secrets, `.env` files in the changeset.

## When to skip this skill

- Read-only static sites with no secrets.
- Tutorial code where intentional simplicity wins.
- Test fixtures with clearly fake values (`sk_test_FAKE_FAKE_FAKE`).

## Related skills

- [`forge-validation`](../../backend/forge-validation/SKILL.md) - startup env validation pattern.
- [`forge-logging`](../../backend/forge-logging/SKILL.md) - redaction config.
- [`forge-auth`](../../backend/forge-auth/SKILL.md) - the layer above secrets.
- [`forge-api-design`](../../backend/forge-api-design/SKILL.md) - canonical error shape (no stack to client).
