Next.js• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

The Next.js Server Action That Leaked User Emails to Google

A Server Action that fetched user profiles returned HTML with email addresses in the response, and Google indexed them all within hours.

The Next.js Server Action That Leaked User Emails

Server Actions in Next.js 14 are magical. Write a function, call it from a form, no API routes needed.

But that magic almost got us sued.

The Setup

I built a user directory page:

// app/users/page.tsx import { getUsers } from './actions'; export default async function UsersPage() { const users = await getUsers(); return ( <div> {users.map(user => ( <div key={user.id}> <h3>{user.name}</h3> <p>{user.bio}</p> {/* Email NOT displayed - private */} </div> ))} </div> ); }

And a Server Action to search users:

// app/users/actions.ts 'use server'; export async function searchUsers(formData: FormData) { const query = formData.get('query'); const users = await db.user.findMany({ where: { OR: [ { name: { contains: query } }, { email: { contains: query } } // ← PROBLEM ] }, select: { id: true, name: true, bio: true, email: true // ← SELECTED, but not displayed } }); // Return to client - email is included! return { users }; }

The Leak

Here's what I didn't understand: Server Actions return data to the client, even if you don't display it.

The search form:

// Search component 'use client'; export function SearchBar() { const [results, setResults] = useState([]); async function handleSearch(formData: FormData) { const data = await searchUsers(formData); setResults(data.users); // users have email property! } return ( <form action={handleSearch}> <input name="query" /> <button>Search</button> </form> ); }

The email field was in the JavaScript bundle. In the network response. In the React component state.

Any user could:

  1. Open DevTools
  2. Search for a name
  3. Inspect the network response
  4. See everyone's email addresses

But worse — Googlebot started crawling the search endpoint.

The Indexing Nightmare

Because the search was a GET request (I used method="GET"), Googlebot found URLs like:

/users?query=a
/users?query=b
/users?query=john

And indexed the responses.

Each response contained:

  • User names
  • User bios
  • User emails

Within 24 hours, searching for "user@example.com" site:myapp.com on Google showed our internal user emails.

The Discovery

A user emailed me:

"Why can I see everyone's email address in the network tab when I search?"

My heart stopped.

I checked the network response:

{ "users": [ { "id": 1, "name": "John Doe", "email": "john.doe@gmail.com", // EXPOSED "bio": "Software engineer" } ] }

The Fix

1. Remove email from Server Action return

// FIXED - Don't return email export async function searchUsers(formData: FormData) { const users = await db.user.findMany({ where: { name: { contains: query } }, select: { id: true, name: true, bio: true // email removed from select } }); return { users }; }

2. Use POST instead of GET for searches

<form action={handleSearch} method="POST"> {/* Googlebot won't crawl POST endpoints */} </form>

3. Add robots.txt to block search endpoints

User-agent: * Disallow: /users?* Disallow: /api/search*

4. Request Google removal

# Using Google Search Console API curl -X POST "https://indexing.googleapis.com/v3/urlNotifications:remove" -H "Authorization: Bearer $TOKEN" -d '{"url": "https://myapp.com/users?query=*"}'

The Deeper Problem

I realized the issue was bigger: Server Actions return the entire response to the client.

Even if you don't display sensitive data, it's still in the network payload.

The fix: Create a DTO (Data Transfer Object):

// Create a public user type type PublicUser = { id: number; name: string; bio: string; // No email }; export async function searchUsers(formData: FormData): Promise<{ users: PublicUser[] }> { const dbUsers = await db.user.findMany({ where: { name: { contains: query } }, select: { id: true, name: true, bio: true, email: true // Still needed for internal logic? } }); // Transform before returning return { users: dbUsers.map(({ id, name, bio }) => ({ id, name, bio })) }; }

What I Learned

1. Never Trust Client-Side Rendering for Security

Just because you don't display data doesn't mean it's not exposed.

2. Server Actions Return Everything

The return value of a Server Action is sent to the client in full.

3. Use POST for State-Changing Operations

GET requests are crawled. POST requests are not.

4. Implement Data Filtering at the Boundary

Create public DTOs for any data leaving the server.

Security Checklist for Server Actions

  • [ ] Does the action return only necessary fields?
  • [ ] Are sensitive fields explicitly excluded?
  • [ ] Is the action using POST (not GET)?
  • [ ] Are there rate limits on the action?
  • [ ] Is the action authenticated/authorized?
  • [ ] Have I inspected the network response?

Commands to Audit Server Actions

Check for exposed fields

# Grep for 'select' in actions grep -r "select:" app/**/actions.ts # Look for returning entire objects grep -r "return.*user" app/**/actions.ts

Test with browser DevTools

// In browser console // Intercept Server Action responses const origFetch = window.fetch; window.fetch = function(...args) { if (args[0].includes('/actions')) { console.log('Server Action:', args); } return origFetch.apply(this, args); };

Monitor Google indexing

# Check if pages are indexed curl -A "Googlebot" https://myapp.com/users?query=test

Conclusion

Next.js Server Actions are convenient. But convenience can hide security risks.

That day taught me: What happens on the server stays on the server — unless you return it to the client.

Now every Server Action has a corresponding DTO type that explicitly excludes sensitive fields.

And I never use GET for actions that return user data.