diff --git a/packages/runtime-core/__tests__/componentPublicInstance.spec.ts b/packages/runtime-core/__tests__/componentPublicInstance.spec.ts
index 47dcb03c3ab..d3a4cef93f7 100644
--- a/packages/runtime-core/__tests__/componentPublicInstance.spec.ts
+++ b/packages/runtime-core/__tests__/componentPublicInstance.spec.ts
@@ -4,7 +4,8 @@ import {
   getCurrentInstance,
   nodeOps,
   createApp,
-  shallowReadonly
+  shallowReadonly,
+  defineComponent
 } from '@vue/runtime-test'
 import { ComponentInternalInstance, ComponentOptions } from '../src/component'
 
@@ -458,4 +459,24 @@ describe('component: proxy', () => {
       )} was accessed during render ` + `but is not defined on instance.`
     ).toHaveBeenWarned()
   })
+
+  test('should prevent mutating script setup bindings', () => {
+    const Comp = defineComponent({
+      render() {},
+      setup() {
+        return {
+          __isScriptSetup: true,
+          foo: 1
+        }
+      },
+      mounted() {
+        expect('foo' in this).toBe(false)
+        try {
+          this.foo = 123
+        } catch (e) {}
+      }
+    })
+    render(h(Comp), nodeOps.createElement('div'))
+    expect(`Cannot mutate <script setup> binding "foo"`).toHaveBeenWarned()
+  })
 })
diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts
index 8f24b8dace4..695df45898a 100644
--- a/packages/runtime-core/src/componentPublicInstance.ts
+++ b/packages/runtime-core/src/componentPublicInstance.ts
@@ -270,6 +270,9 @@ export interface ComponentRenderContext {
 
 export const isReservedPrefix = (key: string) => key === '_' || key === '$'
 
+const hasSetupBinding = (state: Data, key: string) =>
+  state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key)
+
 export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
   get({ _: instance }: ComponentRenderContext, key: string) {
     const { ctx, setupState, data, props, accessCache, type, appContext } =
@@ -280,19 +283,6 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
       return true
     }
 
-    // prioritize <script setup> bindings during dev.
-    // this allows even properties that start with _ or $ to be used - so that
-    // it aligns with the production behavior where the render fn is inlined and
-    // indeed has access to all declared variables.
-    if (
-      __DEV__ &&
-      setupState !== EMPTY_OBJ &&
-      setupState.__isScriptSetup &&
-      hasOwn(setupState, key)
-    ) {
-      return setupState[key]
-    }
-
     // data / props / ctx
     // This getter gets called for every property access on the render context
     // during render and is a major hotspot. The most expensive part of this
@@ -314,7 +304,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
             return props![key]
           // default: just fallthrough
         }
-      } else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
+      } else if (hasSetupBinding(setupState, key)) {
         accessCache![key] = AccessTypes.SETUP
         return setupState[key]
       } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
@@ -403,26 +393,28 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
     value: any
   ): boolean {
     const { data, setupState, ctx } = instance
-    if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
+    if (hasSetupBinding(setupState, key)) {
       setupState[key] = value
       return true
+    } else if (
+      __DEV__ &&
+      setupState.__isScriptSetup &&
+      hasOwn(setupState, key)
+    ) {
+      warn(`Cannot mutate <script setup> binding "${key}" from Options API.`)
+      return false
     } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
       data[key] = value
       return true
     } else if (hasOwn(instance.props, key)) {
-      __DEV__ &&
-        warn(
-          `Attempting to mutate prop "${key}". Props are readonly.`,
-          instance
-        )
+      __DEV__ && warn(`Attempting to mutate prop "${key}". Props are readonly.`)
       return false
     }
     if (key[0] === '$' && key.slice(1) in instance) {
       __DEV__ &&
         warn(
           `Attempting to mutate public property "${key}". ` +
-            `Properties starting with $ are reserved and readonly.`,
-          instance
+            `Properties starting with $ are reserved and readonly.`
         )
       return false
     } else {
@@ -449,7 +441,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
     return (
       !!accessCache![key] ||
       (data !== EMPTY_OBJ && hasOwn(data, key)) ||
-      (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) ||
+      hasSetupBinding(setupState, key) ||
       ((normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key)) ||
       hasOwn(ctx, key) ||
       hasOwn(publicPropertiesMap, key) ||