Security Logging skill for Claude Code
Audit trails that answer who did what, and page on it. Claude gives the app a typed security event vocabulary with actor, target, outcome, and correlation IDs, emitted through one redacting function. It routes the audit trail to storage the app cannot rewrite, sets retention per stream, and writes detections for credential stuffing, privilege grants, IDOR probing, and mass export, then proves redaction with canary secrets.
- name
security-logging - size233 lines · ≈2,850 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs security event logging and detection for applications - a fixed event vocabulary for authentication, authorization, admin, and data-access events, structured JSON schema with actor, target, outcome, and correlation IDs, redaction of secrets and personal data, append-only audit trails with tamper evidence, retention, and alert rules for credential stuffing, privilege changes, and mass export. Use when adding audit logs, preparing for SOC 2 or an incident, logs lack who-did-what, secrets leak into logs, or the team needs detections that page someone. Try asking
- Add an audit log for logins, role changes, and data exports in our Django app
- Check whether we are leaking tokens or passwords into our logs and fix it
- Write alert rules that would catch credential stuffing and mass data export
Use it when
- Adding audit logging or an activity history feature
- Preparing for SOC 2, ISO 27001, or a customer security questionnaire
- An incident showed the logs could not answer who did what
- Secrets or personal data may be leaking into logs or error trackers
Skip it when
- You are digging through existing logs to debug an outage - use log-analysis instead
- You need distributed tracing and metrics rather than security events - use opentelemetry instead
How to check the result
Tests assert each instrumented action emits one well-formed event, canary passwords and tokens never appear in logs or the error tracker, the app role cannot delete audit objects, and each detection fires in staging.
The common mistake it prevents
Logging whole request headers or URLs on auth routes, which writes bearer tokens, cookies, and reset links straight into a log store many people can read.
Done well means: for any security-relevant action you can answer who, what, on which object, from where, when, and with what outcome from one query; logs contain no secrets or raw credentials; the audit trail cannot be silently edited by the people it records; and at least a handful of detections page a human when they fire. Logs nobody alerts on are forensics at best.
Ground rules
- Security events are a product feature with a schema, not
console.logside effects. Emit them through one function with typed event names. - Log the decision, not the payload:
auth.login.failure reason=bad_password, never the password, token, session ID, full card number, or reset link. - Failures and denials matter more than successes. Every
denyfrom the authorization layer is an event. - Use UTC timestamps in RFC 3339 with milliseconds, from NTP-synced clocks.
- The audit trail goes somewhere the application’s own credentials cannot delete or rewrite.
- Personal data in logs is still personal data: minimize, pseudonymize where possible, and apply retention.
Workflow
- [ ] 1. Inventory what is logged today and where it goes
- [ ] 2. Define the event vocabulary and schema
- [ ] 3. Implement one emitter with redaction built in
- [ ] 4. Instrument the event sources (auth, authz, admin, data export, config)
- [ ] 5. Ship to storage with tamper resistance and retention
- [ ] 6. Write detections with thresholds and owners
- [ ] 7. Test emission, redaction, and alerts; run a tabletop query
1. Inventory
# Where logging happens and what might leak
grep -rnE "log(ger)?\.(info|warn|error|debug)|console\.(log|error)|print\(" --include=*.{py,ts,js,go,java,rb} . | wc -l
grep -rniE "log.*(password|passwd|secret|token|authorization|cookie|api[_-]?key|ssn|card)" --include=*.{py,ts,js,go,java,rb} .
# Auth and authz code paths that should emit events
grep -rnlE "login|logout|authenticate|reset_password|mfa|permission|forbidden|403" --include=*.{py,ts,js,go,java,rb} . | head -50
Record the sink (stdout to a collector, CloudWatch, Datadog, Loki, SIEM), retention, and who can delete logs.
2. Event vocabulary and schema
Use dotted names: <domain>.<object>.<verb>[.<outcome>]. The minimum set every app needs:
| Event | When | Severity hint |
|---|---|---|
auth.login.success / auth.login.failure | Every login attempt, with reason code | failure: info, bursts: alert |
auth.mfa.challenge.failure | Wrong or expired second factor | warn |
auth.session.revoked / auth.logout | Logout, forced sign-out | info |
auth.password.reset.requested / .completed | Reset flow | info |
auth.credential.added / .removed | Password change, passkey or MFA device changes | warn |
authz.denied | Policy returned deny | info; bursts: alert |
admin.role.granted / .revoked | Any privilege change | warn, always reviewed |
admin.impersonation.started / .ended | Support acting as a user | warn |
data.export.created | Bulk export, report download, API key with read-all | warn |
apikey.created / .revoked | Token lifecycle | warn |
config.security.changed | SSO settings, IP allowlist, retention, MFA policy | warn |
input.validation.rejected | Repeated malformed or blocked input (aggregated) | info |
For the full catalog with required fields per event and reason codes, read references/event-catalog.md when instrumenting more than the core set or mapping to OCSF or ECS.
Schema (one JSON object per line):
{
"ts": "2026-03-04T10:21:07.123Z",
"event": "authz.denied",
"outcome": "failure",
"reason": "policy_deny",
"actor": { "type": "user", "id": "usr_8f2c", "org_id": "org_19", "session_id_hash": "b1e4..." },
"target": { "type": "invoice", "id": "inv_1043", "org_id": "org_77" },
"action": "invoice:read",
"source": { "ip": "203.0.113.9", "user_agent": "Mozilla/5.0 ...", "geo": "DE" },
"request_id": "req_01HV...", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"service": "billing-api", "env": "prod", "schema_version": 1
}
Rules: actor and target always typed; IDs are internal stable IDs, not emails; session
identifiers are hashed; outcome is one of success | failure | unknown.
3. One emitter with redaction
Python (structlog):
import hashlib, structlog
SENSITIVE_KEYS = {"password", "token", "secret", "authorization", "cookie", "api_key", "otp", "card_number"}
def _redact(_, __, event_dict):
def scrub(obj):
if isinstance(obj, dict):
return {k: ("[REDACTED]" if k.lower() in SENSITIVE_KEYS else scrub(v)) for k, v in obj.items()}
if isinstance(obj, list):
return [scrub(v) for v in obj]
return obj
return scrub(event_dict)
structlog.configure(processors=[
structlog.processors.TimeStamper(fmt="iso", utc=True, key="ts"),
_redact,
structlog.processors.JSONRenderer(),
])
_log = structlog.get_logger("security")
def security_event(event: str, *, outcome: str, actor: dict, target: dict | None = None, **fields):
actor = dict(actor) # never mutate the caller's dict
sid = actor.pop("session_id", None)
if sid:
actor["session_id_hash"] = hashlib.sha256(sid.encode()).hexdigest()[:16]
# structlog stores the first positional argument under the "event" key
_log.info(event, outcome=outcome, actor=actor, target=target, schema_version=1, **fields)
Node (pino) uses built-in path redaction:
import pino from "pino";
export const secLog = pino({
base: { service: process.env.SERVICE_NAME, env: process.env.NODE_ENV },
timestamp: pino.stdTimeFunctions.isoTime,
redact: { paths: ["*.password", "*.token", "*.authorization", "req.headers.cookie", "req.headers.authorization"], censor: "[REDACTED]" },
}).child({ channel: "security" });
secLog.info({ event: "auth.login.failure", outcome: "failure", reason: "bad_password", actor: { type: "user", id: userId }, source: { ip } }, "auth.login.failure");
Redaction by key name is a backstop, not the plan: the primary control is never passing secrets into the emitter. Do not log raw request bodies on auth routes at all.
4. Instrument sources
- Wrap the auth library’s hooks (login success and failure, MFA, reset) rather than every route.
- Emit
authz.deniedinside the centralcheck()(see authorization-models), with subject, action, resource, and policy version. - Admin panels and internal tools are the highest-value audit targets. Every mutation there
emits an
admin.*event with before/after for the changed fields (minus secrets). - Aggregate noisy events (validation rejections) per actor per minute instead of one line each.
5. Storage, tamper resistance, retention
| Need | Default control |
|---|---|
| Ops and debugging logs | Normal log pipeline, 14-30 days |
| Security events | Separate stream or index, 12 months searchable is a common audit expectation |
| Tamper resistance | Ship to a separate account or project the app cannot write to except append; object storage with write-once retention (S3 Object Lock in compliance mode, GCS bucket lock, Azure immutable blob) |
| Integrity evidence | Hash-chain each batch (hash_n = SHA-256(hash_{n-1} || batch_n)) and store the head elsewhere, or rely on provider digests such as CloudTrail log file validation (aws cloudtrail validate-logs) |
| Access | Read via SIEM roles; deletion only by a break-glass role whose use itself alerts |
| Personal data | Pseudonymous IDs in events; the ID-to-person mapping lives in the main DB under its own retention |
An in-database audit_log table is acceptable for product-facing audit history (customers
viewing their org’s activity) but is not tamper-resistant on its own: revoke UPDATE and
DELETE from the app role and replicate to the immutable sink.
6. Detections
Each detection has: query, threshold, window, owner, runbook link, and expected false positives. Start with these:
| Detection | Logic (adapt to your SIEM) | Action |
|---|---|---|
| Credential stuffing | auth.login.failure > 50 distinct usernames from one IP or ASN in 5 min, or failure ratio > 90% over 1,000 attempts globally | Page; enable stricter rate limits (bot-fraud-defense) |
| Account takeover signal | auth.login.success after 5+ failures for same account, from a new country or device | Notify user; step-up next action |
| Privilege escalation | Any admin.role.granted to owner/admin, or outside business hours | Alert security channel; daily review |
| Mass data export | data.export.created rows > 10x actor’s 30-day median, or > N exports in 1 h | Page; consider auto-suspend of token |
| Authorization probing | authz.denied > 20 distinct target IDs per actor in 10 min | Alert; likely IDOR enumeration |
| Audit pipeline silence | Zero security events from a service for 15 min during normal traffic | Page; logging outage hides attacks |
| Break-glass use | Any use of the emergency admin or log-deletion role | Page, always |
Write detections as code where the SIEM allows (Sigma rules, saved searches in Terraform) so they are reviewed and versioned.
Gotchas
- Logging
req.headerswholesale leaksAuthorizationandCookie. Allowlist headers. - URL query strings carry tokens (password reset, OAuth
code, signed URLs). Strip query strings from access logs on sensitive paths. - Exceptions from HTTP clients often include the full request, headers and all. Scrub in
the error reporter too (Sentry
before_send). - User-controlled strings in logs enable log injection: JSON encoding handles newlines; in plain-text logs, escape CR/LF.
- “Username not found” vs “wrong password” in logs is fine; in responses it is enumeration.
- Client IP behind a proxy: trust
X-Forwarded-Foronly from your own load balancer hops, or the attacker chooses the IP you log. - Sampling and log-level filtering must never drop security events. Route them on a channel that is exempt.
- Retention conflicts: privacy rules push for shorter, audit for longer. Decide per stream and write it down (see gdpr-privacy).
Verification
- Unit tests assert that each instrumented action emits exactly one event with required fields (capture the logger output in tests).
- Redaction test: send a login with password
CanaryP@ss-<random>and anAuthorizationheader with a canary token; grep the full log output and the error tracker for both canaries. Zero hits required. - Tamper test: confirm the app’s runtime role gets AccessDenied deleting or overwriting objects in the audit sink.
- Fire each detection in staging with a synthetic burst and confirm the alert reaches the owner with a runbook link.
- Tabletop: answer “what did user X do between T1 and T2, and who granted them admin?” with one query each, in under five minutes.
Output contract
## Security logging: <service>
| Event | Emitted at | Fields verified | Test |
|---|---|---|---|
| auth.login.failure | auth/hooks.py:41 | actor, reason, source.ip | test_login_events |
Sink: <collector> -> <SIEM index> (12 mo) + <immutable bucket> (Object Lock, 400 d)
Redaction canary: 0 hits in logs and error tracker
Detections: <name, threshold, owner, runbook> x N; fired in staging on <date>
Gaps: <sources not yet instrumented> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/event-catalog.md
# Security event catalog
Extended vocabulary with required fields and reason codes. Every event also carries the
common envelope: `ts`, `event`, `outcome`, `actor`, `source`, `request_id`, `trace_id`,
`service`, `env`, `schema_version`.
## Contents
1. Authentication
2. Session and token lifecycle
3. Authorization
4. Administrative and privilege changes
5. Data access and export
6. Configuration and integrations
7. Input and abuse signals
8. Mapping to OCSF and ECS
9. What never to log
---
## 1. Authentication
| Event | Extra required fields | Reason codes |
|---|---|---|
| `auth.login.success` | `method` (password, passkey, sso, magic_link), `mfa_used` (bool), `new_device` (bool) | - |
| `auth.login.failure` | `method`, `username_hash` when no account matched | `bad_password`, `unknown_user`, `locked`, `disabled`, `mfa_required`, `rate_limited`, `sso_error` |
| `auth.mfa.challenge.success` / `.failure` | `factor` (totp, passkey, sms, recovery_code) | `bad_code`, `expired`, `replayed` |
| `auth.lockout.triggered` | `threshold`, `window_seconds` | - |
| `auth.password.reset.requested` | `delivery` (email, sms) | - |
| `auth.password.reset.completed` | `sessions_revoked` (count) | `token_expired`, `token_reused` on failure |
| `auth.sso.assertion.rejected` | `idp`, `issuer` | `bad_signature`, `audience_mismatch`, `expired`, `replay` |
Hash unknown usernames (keyed HMAC, not plain SHA-256) so repeated attempts correlate without
storing what people typed, which is often a password pasted into the wrong field.
## 2. Session and token lifecycle
| Event | Extra required fields |
|---|---|
| `auth.session.created` / `.revoked` | `session_id_hash`, `revoked_by` (self, admin, system), `reason` |
| `auth.credential.added` / `.removed` | `credential_type` (password, passkey, totp, recovery_codes), `credential_id` (non-secret identifier) |
| `apikey.created` / `.revoked` / `.used_first_time` | `key_id` (prefix only, e.g. `sk_live_ab12`), `scopes`, `expires_at` |
| `oauth.grant.created` / `.revoked` | `client_id`, `scopes` |
| `token.refresh.reuse_detected` | `family_id` (refresh token family) |
## 3. Authorization
| Event | Extra required fields |
|---|---|
| `authz.denied` | `action`, `target`, `policy` (name), `policy_version` |
| `authz.elevated` | `action`, `step_up_method` (reauth, passkey) |
| `tenant.boundary.violation` | `actor.org_id`, `target.org_id` - should be zero; any occurrence is a bug or an attack |
## 4. Administrative and privilege changes
| Event | Extra required fields |
|---|---|
| `admin.role.granted` / `.revoked` | `subject` (user receiving the change), `role`, `scope` (org, project) |
| `admin.user.created` / `.disabled` / `.deleted` | `subject` |
| `admin.impersonation.started` / `.ended` | `subject`, `ticket_id`, `duration_seconds` on end |
| `admin.setting.changed` | `setting`, `old_value`, `new_value` (never for secret values; log `rotated: true` instead) |
| `admin.breakglass.used` | `role`, `justification` |
## 5. Data access and export
| Event | Extra required fields |
|---|---|
| `data.export.created` | `resource_type`, `row_count`, `format`, `filter_summary` |
| `data.bulk_read` | `resource_type`, `row_count`, triggered when a single request returns > N rows |
| `data.record.viewed` | Only for regulated records (health, HR, payment): `resource_type`, `target.id` |
| `data.deletion.completed` | `resource_type`, `count`, `request_id` of the DSAR or retention job |
## 6. Configuration and integrations
| Event | Extra required fields |
|---|---|
| `config.security.changed` | `setting` (sso_enforced, mfa_required, ip_allowlist, session_ttl, retention_days), old/new |
| `integration.webhook.signature_failed` | `provider`, `endpoint` - bursts indicate probing or a rotated secret |
| `integration.connected` / `.disconnected` | `provider`, `scopes` |
| `secret.rotated` | `secret_name` (not value), `rotated_by` |
## 7. Input and abuse signals
Aggregate per actor or IP per minute:
| Event | Fields |
|---|---|
| `input.validation.rejected` | `route`, `rule`, `count` |
| `abuse.rate_limited` | `route`, `limit`, `key_type` (ip, user, api_key), `count` |
| `abuse.captcha.failed` | `route`, `count` |
| `upload.rejected` | `reason` (type, size, malware), `content_type_claimed` |
## 8. Mapping to OCSF and ECS
If the SIEM expects a standard schema, map at the collector, not in application code:
| This schema | OCSF (Authentication / API Activity classes) | ECS |
|---|---|---|
| `ts` | `time` | `@timestamp` |
| `event` | `activity_name` plus `class_uid` | `event.action` |
| `outcome` | `status` (Success / Failure) | `event.outcome` |
| `actor.id` | `actor.user.uid` | `user.id` |
| `target.type` / `target.id` | `resources[].type` / `resources[].uid` | custom fields or `related.*` |
| `source.ip` | `src_endpoint.ip` | `source.ip` |
| `source.user_agent` | `http_request.user_agent` | `user_agent.original` |
| `reason` | `status_detail` | `event.reason` |
| `trace_id` | `metadata.correlation_uid` | `trace.id` |
Field names above follow the published schemas at the time of writing; confirm against the
schema version your SIEM ingests.
## 9. What never to log
- Passwords, OTP codes, recovery codes, passkey assertions, private keys.
- Session IDs, bearer tokens, refresh tokens, API keys (log a non-secret prefix or ID).
- Password reset, magic link, and signed download URLs (they are credentials).
- Full payment card numbers, CVV, bank account numbers; government IDs.
- Request and response bodies on auth, payment, and profile routes.
- Health, biometric, or other special-category data unless a regulation requires access
logging, and then only the record ID.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.