
Public APIs in Next.js can unintentionally expose sensitive functionality, especially with the rise of 'use server' functions that create HTTP endpoints under the hood. If left unchecked, these endpoints can become a security risk, allowing unauthorized requests to manipulate your application.
In this post, you'll learn how to identify and secure public HTTP endpoints in your Next.js applications. We'll cover practical techniques like server-side validation, strengthening authentication for server actions, and implementing best practices to avoid accidental exposure. This guide is for developers who want to build more secure applications and avoid common pitfalls when working with Next.js APIs.
🔍 Understanding Public HTTP Endpoints in Next.js
'use server' Functions Are Public HTTP POST Endpoints
In Next.js, when you declare a function with 'use server', it doesn’t stay private to your application. Instead, the Next.js compiler transforms it into a publicly accessible HTTP POST endpoint. This means that any exported function marked with 'use server' can be invoked directly over the network, bypassing the client-side interface.
For example, if you have a function like this:
'use server';
export async function issueRefund(input: { invoiceId: string; amountCents: number }) {
// Logic to update the database
}
Next.js registers this as a public endpoint. Attackers can send a POST request to the endpoint using tools like curl or Postman, provided they know the function's Next-Action ID. This ID is embedded in your application's client-side JavaScript code and can be extracted by inspecting network requests or static assets.
Client-side Interfaces Expose Next-Action IDs
When a client-side component uses a 'use server' function, Next.js includes a hashed action identifier in the JavaScript bundle. This identifier allows the client to invoke the server action when submitting a form or triggering an event. However, it also exposes the function to anyone who inspects the browser's developer tools.
For instance, here’s how an attacker could invoke the issueRefund function:
curl -X POST https://your-app.com/some-route \
-H "Next-Action: <hashed-id>" \
-H "Content-Type: application/json" \
-d '{"invoiceId": "inv_123", "amountCents": 10000}'
Without additional server-side validation, this request will execute the function, regardless of whether the user is authenticated or authorized.
Parent Layout Protections Don't Apply to Server Actions
A common misconception is that server actions inherit the protections of their parent layout or page. For example, if a layout checks for an authenticated session, developers might assume that any server action used within that layout is also protected. This is incorrect.
Server actions are independent entry points. When invoked, they bypass the layout's logic entirely. Even if your layout blocks unauthorized users from accessing a page, an attacker can still call the server action directly, as the layout's authentication logic is not executed during the server action's lifecycle.
Summary of Key Points
| Aspect | Implication |
|---|---|
| 'use server' Functions | Compiled into public HTTP POST endpoints. |
| Next-Action IDs | Exposed in client-side JavaScript, enabling direct invocation. |
| Layout Protections | Do not apply to server actions, leaving them unguarded unless explicitly secured. |
⚙ Implementing Server-side Validation for POST Endpoints
Why Input Validation Matters in Server Functions
Every exported use server function in a Next.js app is essentially a public HTTP POST endpoint. This means any client with your function's identifier can invoke it with arbitrary data—bypassing UI constraints, client-side validations, and even authentication checks if they're not implemented correctly. Without robust server-side validation, attackers can exploit your endpoints to inject unexpected payloads, corrupt your database, or crash your application.
Handling TypeScript Type Erasure
TypeScript types are completely erased at runtime. While they help during development, they offer no protection when your server function processes raw HTTP requests. For example, if you define a function that accepts amountCents: number, nothing stops an attacker from sending malformed or malicious data like {"amountCents": "NaN"} or {"amountCents": -10000}.
To mitigate this, you need to enforce runtime validation for all inputs, ensuring that the data conforms to your expected structure and constraints.
Approaches for Validating Data on the Server-side
The best way to validate data at runtime is to use a schema validation library like zod or yup. These libraries allow you to define strict validation schemas and enforce them before processing incoming data.
Example: Securing a Refund Endpoint
Let’s revisit the vulnerable issueRefund function and enhance it with server-side validation using zod.
// app/actions/billing.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
// Define a Zod schema for runtime validation
const RefundInputSchema = z.object({
invoiceId: z.string().min(1, "Invoice ID is required"),
amountCents: z.number().int().positive("Amount must be a positive integer"),
});
export async function issueRefund(input: unknown) {
// Validate input using the schema
const validatedInput = RefundInputSchema.safeParse(input);
if (!validatedInput.success) {
throw new Error(`Validation failed: ${validatedInput.error.message}`);
}
const { invoiceId, amountCents } = validatedInput.data;
// Proceed with database operations only if input is valid
await db.invoices.update({
where: { id: invoiceId },
data: { status: 'REFUNDED', refundedAmount: amountCents },
});
return { success: true };
}
How the Code Works
- Input Validation: The
RefundInputSchemaensuresinvoiceIdis a non-empty string andamountCentsis a positive integer. - Safe Parsing: The
safeParsemethod validates the input and returns either the parsed data or detailed error messages. - Error Handling: If validation fails, the function throws an error, preventing further execution.
Benefits of This Approach
By validating inputs server-side, you ensure that only properly structured and safe data is processed. This guards against malicious payloads, type mismatches, and unexpected edge cases.
Common Pitfalls to Avoid
- Skipping Validation: Never trust client-side validation alone; always validate inputs on the server.
- Loose Schema Definitions: Be precise with your validation rules. For example, allow only positive integers for monetary values.
- Overlooking Error Handling: Always handle validation errors gracefully and return meaningful feedback. Avoid leaking sensitive details in error messages.
🔒 Strengthening Authentication for Server Actions
Authentication Limitations of Middleware
Middleware in Next.js is often used to enforce authentication or route users based on their session state. However, middleware operates at the edge layer and does not inherently protect server actions. Server actions are independent HTTP POST endpoints, and middleware does not intercept or validate these requests. An attacker with your action's identifier can bypass middleware and directly invoke the action.
This means relying solely on middleware for securing server actions is insufficient. Instead, you must explicitly validate authentication and authorization within each server action itself.
Implementing Explicit Session Checks
To secure server actions, include session validation at the start of each action. This ensures that only authorized users can execute the logic. For example, consider a server action that processes refunds:
// app/actions/billing.ts
'use server';
import { getSession } from '@/lib/auth'; // Your session management utility
import { db } from '@/lib/db';
interface RefundInput {
invoiceId: string;
amountCents: number;
}
export async function issueRefund(input: RefundInput) {
// Explicit session validation
const session = await getSession();
if (!session || !session.user || session.user.role !== 'admin') {
throw new Error('Unauthorized: Admin access required');
}
// Validate the input further if needed
if (input.amountCents <= 0) {
throw new Error('Invalid refund amount');
}
// Proceed with the database mutation
await db.invoices.update({
where: { id: input.invoiceId },
data: { status: 'REFUNDED', refundedAmount: input.amountCents },
});
return { success: true };
}
In this example, the getSession function retrieves and validates the user session. The action ensures that the user has the correct role and that the input is valid before performing the refund.
Using Token-Based Validation Mechanisms
In addition to session-based validation, token-based authentication (e.g., JWT) is an effective approach. Tokens can be included in the request headers and verified in the server action.
// app/actions/secureAction.ts
'use server';
import { verifyToken } from '@/lib/auth'; // Token verification utility
export async function secureAction(data: any) {
const token = data.token; // Assume token is passed in the payload
const user = verifyToken(token); // Decode and validate the token
if (!user || user.role !== 'admin') {
throw new Error('Unauthorized: Invalid token or permissions');
}
// Secure logic here
return { message: 'Action completed successfully' };
}
By validating a token with verifyToken, you ensure that only requests with valid and authorized tokens can execute the action. Always use strong, secure token generation and verification mechanisms.
Pitfalls and Common Errors
- Skipping session checks: Never assume middleware or parent components protect server actions. Always validate sessions or tokens explicitly.
- Exposing all functions: Avoid exporting helper functions from
'use server'files unless absolutely necessary. Keep sensitive logic private. - Trusting TypeScript: Remember, TypeScript types are erased at runtime. Always validate input payloads manually.
📜 Best Practices for Preventing Accidental Exposure
Organizing Server and Client Logic Files
A common mistake is mixing server-side and client-side logic in the same file. This can lead to accidental exposure of server actions as public HTTP endpoints. To avoid this, always separate your server actions into dedicated files under a clearly defined directory, such as app/actions. For example:
// app/actions/billing.ts
'use server';
import { db } from '@/lib/db';
export async function issueRefund(input: { invoiceId: string; amountCents: number }) {
// Server-side logic
return db.invoices.update({
where: { id: input.invoiceId },
data: { status: 'REFUNDED', refundedAmount: input.amountCents },
});
}
Then, import server actions into your components without mixing them with client-side logic:
// app/dashboard/billing/page.tsx
import { issueRefund } from '@/app/actions/billing';
export default function BillingPage() {
return (
<form action={async () => await issueRefund({ invoiceId: 'inv_123', amountCents: 5000 })}>
<button type="submit">Issue Refund</button>
</form>
);
}
Avoiding Unnecessary Exports in 'use server' Files
When a file contains the 'use server' directive, every export in that file becomes a public HTTP endpoint. Avoid exporting functions that are not meant to be invoked externally. For example:
// app/actions/users.ts
'use server';
// ❌ This function is now publicly callable via HTTP, even if it was intended for internal use only
export async function resetUserQuota(userId: string) {
return db.quotas.update({ where: { userId }, data: { used: 0 } });
}
// ✅ Move internal logic to a non-'use server' file
import { resetUserQuota } from './internal/users';
By isolating internal logic in separate modules without the 'use server' directive, you can prevent accidental exposure.
Implementing Access Control for Sensitive Functions
Never rely solely on parent components or layouts for access control. Each server action should implement its own explicit authentication and authorization checks. For example:
// app/actions/billing.ts
'use server';
import { requireAdminSession } from '@/lib/auth';
export async function issueRefund(input: { invoiceId: string; amountCents: number }) {
const session = await requireAdminSession();
if (!session.isAdmin) {
throw new Error('Unauthorized');
}
return db.invoices.update({
where: { id: input.invoiceId },
data: { status: 'REFUNDED', refundedAmount: input.amountCents },
});
}
This ensures that even if someone directly invokes the server action bypassing the UI, they cannot execute sensitive operations without valid credentials.
Common Mistakes to Avoid
- Exporting helper functions from
'use server'files without considering their exposure. - Assuming TypeScript types will enforce runtime validation.
- Skipping session or role checks in server actions.
🧩 Conclusion
Securing public APIs in Next.js requires a thoughtful approach to authentication, authorization, and separation of concerns. By understanding the nuances of server actions and client-server interactions, you can build robust, secure applications. Start by auditing your current implementation, applying best practices, and testing for vulnerabilities. With these steps, you'll be well on your way to safeguarding your Next.js applications.
Key Takeaways
- 'use server' functions act as public endpoints—secure them appropriately.
- Always enforce authentication and authorization explicitly.
- TypeScript types don’t validate data at runtime—use runtime validation tools.
- Harden server actions to protect sensitive operations and data.
- Monitor exposed Next-Action IDs in client-side code to prevent misuse.
- Keep server logic separate to avoid accidental exposure.