Point your coding agent at fenko.nz/specs/app-logging.md and tell it to make the app comply. That is the short version of this post. The rest explains what is in the file and why we wrote it.
The app that could not answer the question
Here is a conversation we have had several times this year, in slightly different forms.
A founder has built something with Lovable, Cursor or Claude Code. It works. It has paying users. Then a customer emails to say they saw someone else’s data, or a researcher sends a polite note about an ID in a URL. The founder asks us a reasonable question: how many records were exposed, to whom, and since when?
And the answer, in every one of those cases, has been: we cannot tell you. The app logged what the agent felt like logging, which was debug output. The hosting platform kept that output for a day. There is no record of who fetched what. The only honest disclosure is “everything, to anyone, since launch”.
That is a logging problem, and it is a cheap one to fix before it happens and an expensive one to fix after.
Twelve-factor got you halfway and stopped
If your agent was trained on anything, it was trained on the Twelve-Factor App. Factor XI, “Treat logs as event streams”, is good advice and every platform you might deploy to implements it. Write to stdout, unbuffered, and let the environment capture, route and store the stream. The app never manages log files.
Read the factor again and notice what is missing. It says nothing about what goes in the stream. It is a routing spec with an empty payload. So an AI-built app is fully twelve-factor compliant with a single line of console.log("user logged in"), and it is just as compliant with no logging at all.
Meanwhile the “let the environment store it” half has a catch that the 2011 text could not have anticipated. The environment stores it for about as long as a lunch break. Vercel keeps runtime logs for one hour on Hobby and one day on Pro. Supabase keeps one day on Free and seven on Pro. Those are the two platforms under a large share of the apps built this year.
Put that next to how long exposure actually lasts. Lovable’s own incident write-up says public project chats and source were readable by any logged-in user from 3 February to 20 April 2026. Reports sent to their bug bounty from 22 February were closed without reaching the security team. Eleven weeks. IBM’s 2025 breach report puts the global average lifecycle, from first access to containment, at 241 days. Your logs expire in hours. The thing you need them for takes months to surface.
The models will not fix this for you
We looked at this from the other side in our post on multi-model pipelines. Of the twelve weakness classes we tracked across the 2026 benchmarks, log injection shipped in 88% of generated builds, and every model on the market failed it the same way. Datadog handed one spec to three models and got six implementations with the same broken object-level authorisation. None of the six logged the denied check, because none of the six performed the check.
So the agent writes the access-control bug, and it also fails to write the log line that would have shown you the bug being exploited. You have to specify both. This post is about the second one.
What to log
OWASP has done the hard thinking here. The Logging Cheat Sheet lists the events, the Logging Vocabulary names them, ASVS 5.0 chapter V16 makes them testable, and the 2025 Top 10 renamed the category to Security Logging and Alerting Failures to make the point that a log without an alert on it is a diary.
Those documents are written for a security team with time to choose. An agent needs the choices made. So we condensed them into one list, cross-checked it against the logging policy we run our own products under, and cut anything that would not help an investigator reconstruct an incident. Six groups.
| Group | Events | The one that matters most |
|---|---|---|
| Authentication | login success and failure, MFA, password reset, token created and revoked, invalid session, lockout | auth.login.failure with a reason and a hashed account reference |
| Authorisation | denied check, role changed | authz.denied on every failed permission check, including inside loops |
| Data | sensitive read, write, delete, export, upload accepted and rejected | data.export with a row count |
| Admin and config | user created and deleted, admin action, setting changed, log level changed | config.changed with the key name and never the value |
| Input and abuse | validation failed, rate limit, rejected request, step out of order | input.rejected.rate_limit |
| System | start, stop, unhandled error, dependency failure or slow, job start and end | app.error with the request ID |
Apps that call a model or expose tools get a seventh group: model calls with token counts and latency, tool invocations, and suspected prompt injection. Prompt bodies stay out of the log by default.
If you only take one row from that table, take authz.denied. An attacker walking object IDs produces a run of them. A bug that grants access to everything produces silence. Either way, the presence or absence of that event is how you tell the two apart later.
What each line carries
The OWASP phrasing is “when, where, who, what”. In field terms that is a UTC timestamp, the app name and version, the environment, the event name, a one-sentence message, a request ID, and for HTTP, the method, the route pattern, the status, the duration, the client IP and the opaque user ID.
{"time":"2026-11-09T03:14:15.926Z","level":"warn","event":"authz.denied",
"msg":"user requested a document they do not own",
"app":"docportal","version":"1.4.2","env":"prod",
"request_id":"01JCGX7M4Q","method":"GET","route":"/documents/:id",
"status":403,"duration_ms":4,"ip":"203.0.113.42","user_id":"u_8f3k",
"data":{"action":"read","resource_type":"document","resource_id":"d_2291"}}
One JSON object per line. The message is static text written by the developer. Anything that came from a user goes under data, through the JSON encoder, with control characters stripped and a length cap. That last sentence is the whole defence against log injection, and it is the part the models skip.
What never goes in
Passwords and their hashes. Session IDs, cookies, JWTs, API keys. Connection strings and anything read from the environment as a secret. Card numbers. Full request or response bodies. Prompts and completions. The value of a config key that just changed.
And users are a user_id, never an email address. For a failed login where there is no user ID yet, log the first eight hex characters of a SHA-256 over the lower-cased login name. An investigator can still group fifty attempts against one account. The log does not hold the address.
Build this as an allow-list of fields the logger is permitted to emit. A deny-list of regexes that scrubs things that look like tokens is a second net, and the agent will offer it as the first. Do not accept that.
Get it off the platform
The app’s job ends at stdout. Yours does not. Ship the stream somewhere that is not the hosting dashboard: a log drain, a collector, an object store. Keep ninety days as a floor, two years for anything tied to an incident. Put it on append-only or object-locked storage so an attacker who owns the app cannot tidy up after themselves.
Then alert on the handful of things that are never routine: five failed logins on one account in ten minutes, a burst of denied authorisation checks from one user, every admin action and role change, every change to the log level itself, an export with an unusual row count.
The templates
Everything above is in one markdown file at fenko.nz/specs/app-logging.md. It is written to be read by an agent: every requirement is a MUST, the defaults are chosen, and section 8 is an acceptance checklist the agent has to run and show output for before it is allowed to say “done”. That checklist includes logging in with a wrong password and grepping the captured output for it.
The page you are reading also links to that file as its machine-readable alternate and embeds a copy in its source, so an agent handed this URL can find it without a second hop.
To wire it into a project, paste this into CLAUDE.md, AGENTS.md or whatever your tool reads:
## Logging
This application follows https://fenko.nz/specs/app-logging.md.
Read that file before touching any logging, auth, authz, data access,
admin or error-handling code. In short:
- One structured JSON logger, stdout only, unbuffered, UTC RFC 3339 time.
- Every line has: time, level, event, msg, app, version, env, request_id.
HTTP lines add method, route, status, duration_ms, ip, user_id.
- Emit every event in section 3 of the spec that applies to this app.
`authz.denied` on every failed permission check is non-negotiable.
- Never log secrets, tokens, session IDs, passwords, card numbers, full
bodies, prompts or completions. Users are `user_id`, never email.
- Escape control characters in user input; user input goes in `data`,
never in `msg`.
- Run the acceptance checks in section 8 before saying a change is done.
Or skip the paste and give the agent one sentence:
Read https://fenko.nz/specs/app-logging.md and make this application
comply with it. Run the acceptance checks in section 8 and show me the
output before you report done.
We will keep the spec versioned at that URL. If you find a row that is missing or one that is wrong for your stack, tell us at security@fenko.nz and we will change it.
