How to test authorization in a Next.js API
Test an owner, an anonymous caller, and a signed-in nonmember, then read persisted data to catch rejected writes that still changed it.
Contents1 section
A Next.js developer asked where authorization checks belong, especially when a page hides controls but its endpoints remain callable. A protected button is not an authorization test. Send the mutation to the API as an owner, without a session, and as a different signed-in user. Then read the stored resource again after each denial. A 403 alone cannot prove the handler did not write first.
The Next.js backend guide treats Route Handlers as public endpoints: each protected operation must check the caller's authority. The example below is a disposable local test of that boundary. Its fixture identities stand in for your real session provider; it does not test login, cookies, or a hosted database.
Create a runnable fixture
In a disposable directory with Bun, save this as and run bun test authorization.test.ts. Bun's SQLite database is a real local file, not an in-memory object. The test opens a fresh connection for every read.
import { test, expect } from 'bun:test';
import { Database } from 'bun:sqlite';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
test('only the owner can rename a resource', async () => {
const dir = mkdtempSync(join(tmpdir(), 'auth-example-'));
const file = join(dir, 'test.sqlite');
const setup = new Database(file);
setup.run('CREATE TABLE resources (id TEXT PRIMARY KEY, owner TEXT, name TEXT)');
setup.run('INSERT INTO resources VALUES (?, ?, ?)', ['r1', 'owner', 'Original']);
setup.close();
// Replace this fixture with your real verified session and repository in an app test.
async function PATCH(request: Request) {
// Test-only identity header; production must verify its session server-side.
const actor = request.headers.get('x-test-user');
if (!actor) return new Response(null, { status: 401 });
const { name } = await request.json() as { name: string };
const db = new Database(file);
try {
const result = db.run('UPDATE resources SET name = ? WHERE id = ? AND owner = ?',
[name, 'r1', actor]);
return new Response(null, { status: result.changes === 1 ? 200 : 403 });
} finally { db.close(); }
}
function readFresh() {
const db = new Database(file, { readonly: true });
try { return db.query('SELECT name FROM resources WHERE id = ?').get('r1') as { name: string }; }
finally { db.close(); }
}
async function rename(actor: string | null, name: string) {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (actor) headers.set('x-test-user', actor);
return PATCH(new Request('http://localhost/api/resources/r1', {
method: 'PATCH', headers, body: JSON.stringify({ name }),
}));
}
try {
expect((await rename('owner', 'Owner edit')).status).toBe(200);
expect(readFresh().name).toBe('Owner edit');
expect((await rename(null, 'Anonymous edit')).status).toBe(401);
expect(readFresh().name).toBe('Owner edit');
expect((await rename('nonmember', 'Other edit')).status).toBe(403);
expect(readFresh().name).toBe('Owner edit');
} finally { rmSync(dir, { recursive: true, force: true }); }
});A passing test invokes this fixture handler with HTTP requests and proves its conditional write and persisted result. It does not start Next.js or send a network request. It does not prove your route is protected. Wire the same cases through your real HTTP endpoint, with separately authenticated clients and the ordinary application database role. Seed only disposable resources, verify the owner succeeds first, and reread through a fresh authorized connection after each rejected request. Never use a privileged service role to stand in for a user's access.
For an existing CoreMVP application, follow the Organizations guide for its membership journey and the private-file access test for a separate storage boundary. CoreMVP's Organizations and Projects provide a persisted example of role checks instead of relying on hidden controls.