We Fixed 20+ Security Issues. The Re-Audit Found 10 More.
After patching every vulnerability from our initial audit, we commissioned a re-audit expecting the all-clear. Two autonomous agents found 10 new issues we'd completely missed — including PII logged in 15 locations and an IPv6 parsing bug that silently broke rate limiting.

The Trap
You fix every vulnerability. You deploy the patches. You write the commit messages. You feel good.
Then you commission a re-audit — just to be sure — and discover you weren't even close to done.
That's exactly what happened to us. Our initial security audit (documented in Finding Auth Bypasses in an A2A App and Go Security Audit) turned up 20+ vulnerabilities across our two-repo architecture: a Next.js frontend and a Go backend. We fixed all of them — CORS reflection, auth bypasses, error leaks, missing CSP, empty validation lists.
Then we asked two autonomous sub-agents to audit the post-fix codebase independently. They found 10 new issues, including 6 rated HIGH.
This is the story of what they found, how we missed it the first time, and what we did about it.
How the Re-Audit Worked
We used two OpenCode sub-agents — each given the full repository context and a security audit prompt. They ran independently, crawled every file, and reported findings without knowledge of each other's output.

The overlap between the two was minimal, which told us something important: human bias in auditing is real. Both agents were given the same brief but found different things. One focused on log analysis and token management. The other found infrastructure blind spots and edge-case bugs. Together they painted a much more complete picture than either could alone.
The 10 Findings, Organized
The issues fell into four categories. Here they are, from most pervasive to most specific.
1. PII Was Everywhere
This was the biggest category — 6 of the 10 findings involved Personally Identifiable Information leaking into logs and debug output.
Backend logs (15+ locations across 6 files): Our Go backend was logging user emails, child names, story titles, and image URLs at INFO level. Every handler that processed user data had a log line like this:
log.Printf("Aria choices saved: character=%s setting=%s themes=%v session=%s",
session.Character, session.Setting, session.Themes, session.ID,
)Or this in the LLM agent orchestrator:
log.Printf("weave: turn=%d user=%q", turnState.TurnCount, truncate(userText, 40))
log.Printf("weave: userMsg=%q", truncate(userMsg, 80))The truncate function was supposed to make these safe — but "safe" meant "shorter," not "PII-free." A 40-character snippet of a child's story still contains plot details, character names, and potentially identifying information.
Even in scripts like backfill-usage/main.go:
log.Printf("WARN: no uid for story %s (email=%s), skipping", s.ID, s.UserEmail)Frontend logs (3 files): The browser side was just as bad:
console.log("[getStoryApi] response:", {
has_cover: data.cover_image,
scene_images_keys: Object.keys(data.scene_images),
scene_order: data.scene_order,
});This logged the full story response structure — including which scenes exist, their order, and whether a glossary image is present — to the browser console. Anyone with DevTools open could map the data model.
The auth flow logged search parameters and cookie state:
console.log("[Auth] sign-in complete");
console.log("[Auth] sign-out complete, cookie cleared");And login-form.tsx logged the full search params on every page load:
console.log(
"[login] page loaded, __session cookie:",
hasCookie ? "PRESENT" : "MISSING",
"searchParams:",
Object.fromEntries(searchParams.entries()),
);The fix: Every log line was reviewed and either removed or replaced with opaque identifiers:
log.Printf("weave: turn=%d", turnState.TurnCount)
log.Printf("weave: userMsg prepared (len=%d)", len(userMsg))
log.Printf("Aria choices saved (session=%s)", session.ID)
log.Printf("WARN: no uid for story %s, skipping", s.ID)The frontend logs were simply removed — they served no production purpose and existed only for development debugging that had long since ended.
Why we missed it: We were looking for _vulnerabilities_ — auth bypasses, injection points, access control gaps. PII in logs didn't register as a security issue because we'd normalized it. Every developer on the team had seen these log lines a hundred times. They'd become invisible.
2. Infrastructure Blind Spots
Two findings that were hiding in plain sight.
No Content-Security-Policy: We had security headers — HSTS, X-Frame-Options, X-Content-Type-Options — all configured in next.config.ts. But CSP was missing. An XSS anywhere in the application would have been fully exploitable because there was no policy restricting where scripts, images, or connections could originate.
The fix was adding a strict policy:
{
key: "Content-Security-Policy",
value:
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' https://storage.googleapis.com https://firebasestorage.googleapis.com data: blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://*.firebaseio.com https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://api.frankfurter.dev; frame-src 'self' https://fable-495209.firebaseapp.com; object-src 'none'; base-uri 'self'; form-action 'self'",
},Orphaned Firestore client-side code: The firestore.ts file in lib/firebase/ had been dead code since we migrated all consumers to the Go backend API months ago. But it still existed — initializing a Firestore client, importing getFirestore, setting up the database connection. This meant the client-side Firestore attack surface was alive despite no code using it.
Worse, config.ts still ran Firestore initialization on every app load:
const { getFirestore } = await import("firebase/firestore/lite");
_db = getFirestore(
app,
process.env.NEXT_PUBLIC_FIREBASE_DATABASE_ID || "storyclub-dev",
);The fix was deletion — remove firestore.ts entirely and strip the unused Firestore init from config.ts, along with a console.warn that had been logging "Firebase API key not configured" on every page load in development.
Why we missed it: These were both "works fine" problems. The app ran, CSP wasn't blocking anything (because there was no CSP to block anything), and the Firestore initialization was silently succeeding without any visible impact. No crash, no error, no signal that anything was wrong.
3. Token and Session Management Gaps
Google TTS OAuth — re-authenticating every call: Every Text-to-Speech request to Google Cloud was calling google.FindDefaultCredentials and requesting a new OAuth token:
creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform")
token, err := creds.TokenSource.Token()Each call involves a metadata server lookup (on Cloud Run) or filesystem read (ADC). For a single TTS call this is negligible. For a batch of story narration, it adds significant latency.
The fix was a cached token source:
var (
googleCredsMu sync.Mutex
googleTokenSrc oauth2.TokenSource
)
func getGoogleToken(ctx context.Context) (*oauth2.Token, error) {
googleCredsMu.Lock()
defer googleCredsMu.Unlock()
if googleTokenSrc != nil {
tok, err := googleTokenSrc.Token()
if err == nil {
return tok, nil
}
}
creds, _ := google.FindDefaultCredentials(ctx, ...)
googleTokenSrc = creds.TokenSource
return googleTokenSrc.Token()
}Now the token source is cached after the first call. Google's TokenSource.Token() handles automatic refresh when the token expires — we just don't re-discover credentials every time.
No server-side session revocation: When a user logged out, the frontend deleted the __session cookie and called DELETE /api/auth/session. But the backend handler was a no-op — it just cleared the cookie and returned 200:
func handleDeleteSession(w http.ResponseWriter, _ *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "__session", Value: "", MaxAge: -1,
})
w.WriteHeader(http.StatusOK)
}The Firebase ID token behind the session remained valid. If an attacker had captured the token, the "logout" did nothing to invalidate it.
The fix adds RevokeRefreshTokens before clearing the cookie:
func handleDeleteSession(w http.ResponseWriter, r *http.Request) {
uid, _ := GetUID(r.Context())
if uid != "" {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := firebaseAuth.RevokeRefreshTokens(ctx, uid); err != nil {
log.Printf("Session: revoke tokens failed: %v", err)
}
}
http.SetCookie(w, &http.Cookie{
Name: "__session", Value: "", MaxAge: -1,
})
w.WriteHeader(http.StatusOK)
}Why we missed it: The TTS caching issue looked like a performance problem, not a security issue. And the session revocation gap was invisible — the logout flow "worked" from the user's perspective. Cookie deleted, UI updated, redirect happened. Nobody noticed the backend wasn't doing anything.
4. Edge Case Bugs
IPv6 parsing in the rate limiter: This was the most technically interesting finding. Our clientIP() function extracted the client IP from r.RemoteAddr using string slicing:
func clientIP(r *http.Request) string {
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}For IPv4 like 192.168.1.1:8080, LastIndex(":") finds the last colon (the port separator) and strips it correctly. Trivial.
For IPv6 like [::1]:8080, LastIndex(":") finds... well, let's trace it. The RemoteAddr value is [::1]:8080. The last colon is between ] and 8080 — the port separator. So ip[:idx] gives [::1] with the closing bracket. Close enough — the rate limiter bucket key would be [::1] instead of ::1. It still works because all requests from ::1 would get the same bucket.
But what about IPv6 with a zone ID like fe80::1%eth0:8080? Now LastIndex(":") finds the colon before 8080, and slicing gives fe80::1%eth0 — which is the correct IP. Still works.
So what's the actual bug? It's when there's no port — like when RemoteAddr contains only an IPv6 address without a port, or when the X-Forwarded-For header (handled by a different middleware) passes a bare IPv6 address. In that case, LastIndex(":") finds a colon _inside_ the IPv6 address, and slicing truncates it to fe80 or some other fragment.

The fix uses Go's standard library:
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}net.SplitHostPort handles all IPv6 formats correctly, including bracket notation and zone IDs.
Why we missed it: We were developing on IPv4. The function worked perfectly on every machine we tested. The IPv6 bug only manifests if you're running with an IPv6 network stack or behind certain proxies. "Works on my machine" hid a real correctness bug.
The 4 We Skpped
Not every finding got fixed. Four issues were categorized but not addressed:
SameSite=Lax — modern browsers block cross-site POSTs. Low residual risk.method + path only in practice. The raw error is in the log body, not the panic message. Low signal.None of these are launch-blocking. Each represents a marginal improvement that will be addressed in upcoming sprints.
Results
net.SplitHostPortRevokeRefreshTokens on logoutTotal: 10 findings, 10 fixed, 4 identified and deferred.
Takeaways
1. One audit is never enough.
The first audit found 20+ issues. We fixed them all. Then a re-audit found 10 more. This isn't a failure of the first audit — it's a property of security work. Every pass through the codebase with fresh eyes (or autonomous agents) reveals something the previous pass normalized.
2. Independent agents catch what humans normalize.
The two sub-agents had minimal overlap in their findings. They approached the codebase from different angles — one focused on data flow and logging, the other on configuration and infrastructure. This is exactly what you want from a red-teaming exercise: multiple independent perspectives.
3. "It works" is not a security signal.
The Firestore initialization was running on every page load. The CSP was missing. The session revocation was a no-op. None of these broke anything. None of them caused errors. They were invisible because they were _functional_ — just also insecure. Security isn't about what breaks; it's about what's exploitable.
4. The most dangerous bugs are the ones that work perfectly on your machine.
The IPv6 parsing bug in clientIP() passed every test we ran because we tested on IPv4. The PII logs passed every review because every developer had seen them before. Identify your blind spots and automate the search for them.
5. Blog writing as a forcing function.
Writing this post forced me to categorize every finding, explain why we missed it, and justify why four items remain unfixed. If you're not writing down your security work, you're probably not thinking about it clearly enough.
_Check more open source repos at: github.com/mnkrana/_