Back to blog
Jun 13, 2026

We Fixed 20+ Security Issues. The Re-Audit Found 10 More.

SecurityCoding AgentsAuditGoNext.jsFirebase

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.

We Fixed 20+ Security Issues. The Re-Audit Found 10 More.

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.

Flow diagram: initial audit, 20+ fixes, re-audit by two agents, 10 new findings, all fixed

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.

Side-by-side code comparison: strings.LastIndex breaking on IPv6 vs net.SplitHostPort

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:

#
Issue
Why We Skipped
M-new1
No CSRF on session creation
Session cookie uses SameSite=Lax — modern browsers block cross-site POSTs. Low residual risk.
M-new2
No Subresource Integrity
Firebase SDKs and Google Fonts load dynamically — SRI hashes would break on CDN rotation. Tracked for future pinning.
M-new7
No audit log for auth events
Important but requires storage (Firestore events) and a structured logging system. Bigger than a one-line fix.
M-new8
Panic recovery logs raw errors
Currently logs 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

#
Issue
Severity
What We Did
H-new1
PII in backend logs (15+ locations, 6 files)
HIGH
Replaced emails/names/story content with opaque IDs
H-new2
TTS API keys in request headers logged
HIGH
Removed debug logging; cached provider config
H-new3
IPv6 parsing breaks rate limiter
HIGH
Replaced string slice with net.SplitHostPort
H-new4
Orphaned Firestore client-side attack surface
HIGH
Deleted dead file; stripped unused Firestore init
H-new5
PII sent in URL query params to wrong endpoints
HIGH
Removed email from query params; use ID token auth
H-new6
PII in login-form.tsx console.log
HIGH
Replaced with generic error log
M-new3
No TTS provider config validation
MEDIUM
Added fatal check on startup
M-new4
No server-side session revocation
MEDIUM
Added RevokeRefreshTokens on logout
M-new5
No Content-Security-Policy header
MEDIUM
Added CSP to security headers
M-new6
Story response structure logged in api.ts
MEDIUM
Removed debug logging

Total: 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/_