| name | protect-spa-and-api |
| description | Protect a React/Next.js SPA with route guards and middleware, and protect an API with token-verification middleware (signature + iss + aud + exp). Covers CORS configuration and CSRF defense. Applies to both the web-app and the API/backend targets. |
Skill: protect-spa-and-api
Invoked by: any agent implementing route protection for a Next.js app or API token verification; ravenclaude-core/security-reviewer for any auth middleware change.
When to invoke: adding protected routes to a Next.js app; implementing API token verification; hardening an existing app against CORS or CSRF vulnerabilities; onboarding a new service to require authentication.
Output: Next.js middleware route guard + API token-verification middleware + CORS config + CSRF defense + verification checklist.
Boundary
This skill protects routes and APIs using the authenticated identity established by google-sso-setup and session-and-token-management. Authorization (which authenticated user can access which resource) is handled by the authorization-rbac skill and, for data rows, by the data-platform plugin's rls-policy-authoring skill. Security-sensitive code routes to ravenclaude-core/security-reviewer before production deploy.
Protecting the Next.js web app
Approach A — Next.js Middleware (recommended for App Router)
middleware.ts at the project root runs on every matched request, before rendering. The best place to enforce authentication globally.
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const PUBLIC_PATHS = ["/", "/login", "/auth/callback", "/auth/error"];
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request: { headers: request.headers } });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cs) => {
cs.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request: { headers: request.headers } });
cs.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
const { data: { user } } = await supabase.auth.getUser();
const path = request.nextUrl.pathname;
const isPublic = PUBLIC_PATHS.some((p) => path === p || path.startsWith(p + "/"));
if (!user && !isPublic) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = "/login";
redirectUrl.searchParams.set("next", path);
return NextResponse.redirect(redirectUrl);
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Important: use supabase.auth.getUser() (makes a network call to validate the JWT server-side), not supabase.auth.getSession() (trusts the cookie without validation). [unverified — confirm this distinction in current Supabase Auth docs]
Approach B — Server Component redirect
For per-page protection in App Router Server Components:
import { redirect } from "next/navigation";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export default async function DashboardPage() {
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} } },
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login?next=/dashboard");
return <div>Dashboard for {user.email}</div>;
}
Use Middleware (Approach A) for broad protection + Server Component checks for fine-grained page-level control.
Protecting the API
Every protected API route must verify the caller's identity server-side. Never trust client-supplied identity claims without verification.
Supabase-backed API routes
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} } },
);
const { data: { user }, error } = await supabase.auth.getUser();
if (!user || error) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data } = await supabase.().();
.(data);
}
External / standalone API (Bearer token)
For APIs not behind Supabase's client, verify the JWT from the Authorization: Bearer <token> header:
import { createRemoteJWKSet, jwtVerify } from "jose";
const GOOGLE_JWKS = createRemoteJWKSet(
new URL("https://www.googleapis.com/oauth2/v3/certs"),
);
export async function verifyGoogleIdToken(token: string) {
const { payload } = await jwtVerify(token, GOOGLE_JWKS, {
issuer: "https://accounts.google.com",
audience: process.env.GOOGLE_CLIENT_ID!,
});
return payload;
}
export async function GET(request: Request) {
const authHeader = request.headers.get("Authorization");
(!authHeader?.()) {
(, { : });
}
token = authHeader.();
{
claims = (token);
} {
(, { : });
}
}
Checklist for token verification:
CORS configuration
CORS controls which origins can make cross-origin requests to your API.
const nextConfig = {
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "https://app.example.com" },
{ key: "Access-Control-Allow-Methods", value: "GET, POST, PUT, DELETE, OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
{ key: "Access-Control-Allow-Credentials", value: "true" },
],
},
];
},
};
CORS rules:
- Do not use
Access-Control-Allow-Origin: * for authenticated APIs.
- Specify an explicit allowlist of origins.
Access-Control-Allow-Credentials: true requires a non-wildcard origin.
- CORS is not a security boundary for server-to-server calls (it only applies in browsers).
CSRF defense
Cookies are sent automatically by the browser on cross-site requests. Mitigations:
SameSite=Lax or Strict cookie flag — primary defense; see session-and-token-management skill.
- CSRF token for state-mutating routes — double-submit cookie or synchronizer token pattern for POST/PUT/DELETE routes that cannot rely on
SameSite=Strict alone.
Content-Type: application/json check — simple APIs can reject requests that don't send the correct Content-Type header (pre-flight required for non-simple CORS requests).
- Origin/Referer header validation — secondary check; referer can be stripped by privacy settings.
Supabase API routes benefit from the built-in cookie SameSite protection when using @supabase/ssr. [unverified — confirm in current Supabase SSR docs]
Anti-patterns this skill flags
- Using
supabase.auth.getSession() in middleware instead of getUser() — getSession trusts the unverified cookie
- Returning sensitive data from an API route without verifying the session first
Access-Control-Allow-Origin: * on authenticated API routes
- No
SameSite flag on auth cookies — CSRF-vulnerable
- Skipping
iss or aud validation in manual token verification — replay across issuers/apps
- Accepting
alg: none in JWT verification — allows unsigned tokens
- Protecting only page routes but not API routes (API must enforce auth independently)
- Redirect-after-auth using an unvalidated
next query parameter — open redirect vulnerability; validate next is a relative path on your own domain
Verification checklist
See also