diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc
index 93d769b7d..58fa46908 100644
--- a/.cursor/rules/security.mdc
+++ b/.cursor/rules/security.mdc
@@ -126,7 +126,7 @@ Consider using OTP tokens when implementing highly destructive operations like d
### Personal Information
-- Never log or expose sensitive user data
+- Never log or expose sensitive user data (api keys, passwords, secrets, etc.)
- Use proper session management
## Error Handling
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
index 1074a3018..1ee3c0840 100644
--- a/.junie/guidelines.md
+++ b/.junie/guidelines.md
@@ -1424,7 +1424,6 @@ You always must use `(security_invoker = true)` for views.
SELECT public.is_set('enable_team_accounts');
```
-
# Server Actions
- For Data Mutations from Client Components, always use Server Actions
@@ -1458,7 +1457,6 @@ export const myServerAction = enhanceAction(
);
```
-
# Route Handler / API Routes
- Use Route Handlers when data fetching from Client Components
@@ -1505,8 +1503,6 @@ export const GET = enhanceRouteHandler(
);
```
-
-
# Access Control & Permissions Guidelines
This rule provides guidance for implementing access control, permissions, and subscription-related functionality in the application.
@@ -1712,4 +1708,154 @@ Here's a brief overview of the available namespaces:
- **billing**: Subscription and payment [billing.json](mdc:apps/web/public/locales/en/billing.json)
- **marketing**: Landing pages, blog, etc. [marketing.json](mdc:apps/web/public/locales/en/marketing.json)
-When creating a new functionality, it can be useful to add a new namespace.
\ No newline at end of file
+When creating a new functionality, it can be useful to add a new namespace.
+
+# Security Best Practices
+
+## Next.js-Specific Security
+
+### Client Component Data Passing
+
+- **Never pass sensitive data** to Client Components
+- **Never pass unsanitized data** to Client Components (raw cookies, client-provided data)
+
+### Server Components Security
+
+- **Always sanitize user input** before using in Server Components
+- **Validate cookies and headers** in Server Components
+
+### Environment Variables
+
+- **Use `import 'server-only'`** for code that should only be run on the server side
+- **Never expose server-only env vars** to the client
+- **Never pass environment variables as props to client components** unless they're suffixed with `NEXT_PUBLIC_`
+- **Never use `NEXT_PUBLIC_` prefix** for sensitive data (ex. API keys, secrets)
+- **Use `NEXT_PUBLIC_` prefix** only for client-safe variables
+
+### Client Hydration Protection
+
+- **Never expose sensitive data** in initial HTML
+
+## Authentication & Authorization
+
+### Row Level Security (RLS)
+
+- **Always enable RLS** on all tables unless explicitly specified otherwise [database.mdc](mdc:.cursor/rules/database.mdc)
+
+### Super Admin Protected Routes
+Always perform extra checks when writing Super Admin code [super-admin.mdc](mdc:.cursor/rules/super-admin.mdc)
+
+## Server Actions & API Routes
+
+### Server Actions
+
+- Always use `enhanceAction` wrapper for consistent security [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc)
+- Always use 'use server' directive at the top of the file to safely bundle server-side code
+- Validate input with Zod schemas
+- Implement authentication checks:
+
+```typescript
+'use server';
+
+import { enhanceAction } from '@kit/next/actions';
+import { MyActionSchema } from '../schema';
+
+export const secureAction = enhanceAction(
+ async function(data, user) {
+ // Additional permission checks
+ const hasPermission = await checkUserPermission(user.id, data.accountId, 'action.perform');
+ if (!hasPermission) throw new Error('Insufficient permissions');
+
+ // Validated data available
+ return processAction(data);
+ },
+ {
+ auth: true,
+ schema: MyActionSchema
+ }
+);
+```
+
+### API Routes
+
+- Use `enhanceRouteHandler` for consistent security [route-handlers.mdc](mdc:.cursor/rules/route-handlers.mdc)
+- Implement authentication and authorization checks:
+
+```typescript
+import { enhanceRouteHandler } from '@kit/next/routes';
+import { RouteSchema } from '../schema';
+
+export const POST = enhanceRouteHandler(
+ async function({ body, user, request }) {
+ // Additional authorization checks
+ const canAccess = await canAccessResource(user.id, body.resourceId);
+
+ if (!canAccess) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // Safe to process with validated body
+ return NextResponse.json({ success: true });
+ },
+ {
+ auth: true,
+ schema: RouteSchema
+ }
+);
+```
+
+## Client Components Security
+
+### Context Awareness
+
+- Use appropriate workspace contexts for access control:
+ - `useUserWorkspace()` in personal account pages
+ - `useTeamAccountWorkspace()` in team account pages
+- Check permissions before rendering sensitive UI elements:
+
+```typescript
+function SecureComponent() {
+ const { account, user } = useTeamAccountWorkspace();
+ const canEdit = account.permissions.includes('entity.update');
+
+ if (!canEdit) return null;
+
+ return ;
+}
+```
+
+### Data Fetching
+
+- Use React Query with proper error handling
+- Never trust client-side permission checks alone
+
+## One-Time Tokens
+
+Consider using OTP tokens when implementing highly destructive operations like deleting an entity that would otherwise require a full backup: [otp.mdc](mdc:.cursor/rules/otp.mdc)
+
+## Critical Data Protection
+
+### Personal Information
+
+- Never log or expose sensitive user data
+- Use proper session management
+
+## Error Handling
+
+- Never expose internal errors to clients
+- Log errors securely with appropriate context
+- Return generic error messages to users
+
+```typescript
+try {
+ await sensitiveOperation();
+} catch (error) {
+ logger.error({ error, context }, 'Operation failed');
+ return { error: 'Unable to complete operation' };
+}
+```
+
+## Database Security
+
+- Avoid dynamic SQL generation
+- Use SECURITY DEFINER functions sparingly and carefully, warn user if you do so
+- Implement proper foreign key constraints
+- Use appropriate data types with constraints
\ No newline at end of file
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 6d62b5cf0..20e10574e 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,3 +1,5 @@
+import { headers } from 'next/headers';
+
import { Toaster } from '@kit/ui/sonner';
import { RootProviders } from '~/components/root-providers';
@@ -20,11 +22,12 @@ export default async function RootLayout({
const { language } = await createI18nServerInstance();
const theme = await getRootTheme();
const className = getFontsClassName(theme);
+ const nonce = await getCspNonce();
return (
-
+
{children}
@@ -33,3 +36,9 @@ export default async function RootLayout({
);
}
+
+async function getCspNonce() {
+ const headersStore = await headers();
+
+ return headersStore.get('x-nonce') ?? undefined;
+}
diff --git a/apps/web/components/root-providers.tsx b/apps/web/components/root-providers.tsx
index bc303a2b0..df246d8c7 100644
--- a/apps/web/components/root-providers.tsx
+++ b/apps/web/components/root-providers.tsx
@@ -37,14 +37,21 @@ const CaptchaTokenSetter = dynamic(async () => {
};
});
+type RootProvidersProps = React.PropsWithChildren<{
+ // The language to use for the app (optional)
+ lang?: string;
+ // The theme (light or dark or system) (optional)
+ theme?: string;
+ // The CSP nonce to pass to scripts (optional)
+ nonce?: string;
+}>;
+
export function RootProviders({
lang,
theme = appConfig.theme,
+ nonce,
children,
-}: React.PropsWithChildren<{
- lang?: string;
- theme?: string;
-}>) {
+}: RootProvidersProps) {
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
return (
@@ -63,6 +70,7 @@ export function RootProviders({
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
+ nonce={nonce}
>
{children}
diff --git a/apps/web/lib/create-csp-response.ts b/apps/web/lib/create-csp-response.ts
new file mode 100644
index 000000000..397ab7e3d
--- /dev/null
+++ b/apps/web/lib/create-csp-response.ts
@@ -0,0 +1,94 @@
+import type { NoseconeOptions } from '@nosecone/next';
+
+// we need to allow connecting to the Supabase API from the client
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
+
+// the URL used for Supabase Realtime
+const WEBSOCKET_URL = SUPABASE_URL.replace('https://', 'ws://').replace(
+ 'http://',
+ 'ws://',
+);
+
+// disabled to allow loading images from Supabase Storage
+const CROSS_ORIGIN_EMBEDDER_POLICY = false;
+
+/**
+ * @name ALLOWED_ORIGINS
+ * @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
+ */
+const ALLOWED_ORIGINS = [
+ SUPABASE_URL,
+ WEBSOCKET_URL,
+ // add here additional allowed origins
+] as never[];
+
+/**
+ * @name IMG_SRC_ORIGINS
+ */
+const IMG_SRC_ORIGINS = [SUPABASE_URL] as never[];
+
+/**
+ * @name UPGRADE_INSECURE_REQUESTS
+ * @description Upgrade insecure requests to HTTPS when in production
+ */
+const UPGRADE_INSECURE_REQUESTS = process.env.NODE_ENV === 'production';
+
+/**
+ * @name createCspResponse
+ * @description Create a middleware with enhanced headers applied (if applied).
+ */
+export async function createCspResponse() {
+ const {
+ createMiddleware,
+ withVercelToolbar,
+ defaults: noseconeConfig,
+ } = await import('@nosecone/next');
+
+ /*
+ * @name allowedOrigins
+ * @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
+ */
+
+ const config: NoseconeOptions = {
+ ...noseconeConfig,
+ contentSecurityPolicy: {
+ directives: {
+ ...noseconeConfig.contentSecurityPolicy.directives,
+ connectSrc: [
+ ...noseconeConfig.contentSecurityPolicy.directives.connectSrc,
+ ...ALLOWED_ORIGINS,
+ ],
+ imgSrc: [
+ ...noseconeConfig.contentSecurityPolicy.directives.imgSrc,
+ ...IMG_SRC_ORIGINS,
+ ],
+ upgradeInsecureRequests: UPGRADE_INSECURE_REQUESTS,
+ },
+ },
+ crossOriginEmbedderPolicy: CROSS_ORIGIN_EMBEDDER_POLICY,
+ };
+
+ const middleware = createMiddleware(
+ process.env.VERCEL_ENV === 'preview' ? withVercelToolbar(config) : config,
+ );
+
+ // create response
+ const response = await middleware();
+
+ if (response) {
+ const contentSecurityPolicy = response.headers.get(
+ 'Content-Security-Policy',
+ );
+
+ const matches = contentSecurityPolicy?.match(/nonce-([\w-]+)/) || [];
+ const nonce = matches[1];
+
+ // set x-nonce header if nonce is found
+ // so we can pass it to client-side scripts
+ if (nonce) {
+ response.headers.set('x-nonce', nonce);
+ }
+ }
+
+ return response;
+}
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
index 87e312e82..0101fed74 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -24,7 +24,8 @@ const getUser = (request: NextRequest, response: NextResponse) => {
};
export async function middleware(request: NextRequest) {
- const response = NextResponse.next();
+ const secureHeaders = await createResponseWithSecureHeaders();
+ const response = NextResponse.next(secureHeaders);
// set a unique request ID for each request
// this helps us log and trace requests
@@ -59,7 +60,7 @@ export async function middleware(request: NextRequest) {
async function withCsrfMiddleware(
request: NextRequest,
- response = new NextResponse(),
+ response: NextResponse,
) {
// set up CSRF protection
const csrfProtect = createCsrfProtect({
@@ -100,7 +101,7 @@ async function adminMiddleware(request: NextRequest, response: NextResponse) {
const isAdminPath = request.nextUrl.pathname.startsWith('/admin');
if (!isAdminPath) {
- return response;
+ return;
}
const {
@@ -222,3 +223,21 @@ function matchUrlPattern(url: string) {
function setRequestId(request: Request) {
request.headers.set('x-correlation-id', crypto.randomUUID());
}
+
+/**
+ * @name createResponseWithSecureHeaders
+ * @description Create a middleware with enhanced headers applied (if applied).
+ * This is disabled by default. To enable set ENABLE_STRICT_CSP=true
+ */
+async function createResponseWithSecureHeaders() {
+ const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false';
+
+ // we disable ENABLE_STRICT_CSP by default
+ if (enableStrictCsp === 'false') {
+ return {};
+ }
+
+ const { createCspResponse } = await import('./lib/create-csp-response');
+
+ return createCspResponse();
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 47353972c..2c6e44f04 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -54,6 +54,7 @@
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@marsidev/react-turnstile": "^1.1.0",
+ "@nosecone/next": "1.0.0-beta.6",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.49.4",
"@tanstack/react-query": "5.74.4",
diff --git a/package.json b/package.json
index 1c78543a8..5c24f6ccb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
- "version": "2.7.1",
+ "version": "2.8.0",
"private": true,
"sideEffects": false,
"engines": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 388f62667..2d20a02a9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -196,6 +196,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.1.0
version: 1.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@nosecone/next':
+ specifier: 1.0.0-beta.6
+ version: 1.0.0-beta.6(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.1.0)
@@ -2172,6 +2175,12 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@nosecone/next@1.0.0-beta.6':
+ resolution: {integrity: sha512-WIda5bNyUdm5M653IiPhdFuYz7to4/b/sdgZtldTrTihbiJQEq9KbpMvtAKvPSTeqn6W4y3Io/dMF5bBQie9Fw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ next: '>=14'
+
'@opentelemetry/api-logs@0.50.0':
resolution: {integrity: sha512-JdZuKrhOYggqOpUljAq4WWNi5nB10PmgoF0y2CvedLGXd0kSawb/UBnWT8gg1ND3bHCNHStAIVT0ELlxJJRqrA==}
engines: {node: '>=14'}
@@ -6710,6 +6719,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ nosecone@1.0.0-beta.6:
+ resolution: {integrity: sha512-h+cxBMOuv8VaRwBAt9/W9+PJfgi4N7EcjHwxwl1Rf0d9cFen1zphg4qooydUnZ+9ezeBzg64xMcWFROLy4QKDg==}
+ engines: {node: '>=18'}
+
npm-normalize-package-bin@4.0.0:
resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -9099,6 +9112,11 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@nosecone/next@1.0.0-beta.6(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
+ dependencies:
+ next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ nosecone: 1.0.0-beta.6
+
'@opentelemetry/api-logs@0.50.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -14620,6 +14638,8 @@ snapshots:
normalize-path@3.0.0: {}
+ nosecone@1.0.0-beta.6: {}
+
npm-normalize-package-bin@4.0.0: {}
npm-run-path@4.0.1: