diff --git a/src/main/java/it/inaf/oats/vospace/UriService.java b/src/main/java/it/inaf/oats/vospace/UriService.java
index 0f23ddbbeb80ec0b6335ffc736125df6ccb13622..056aa04248b782adcbdd0576ecb876e8d7256b46 100644
--- a/src/main/java/it/inaf/oats/vospace/UriService.java
+++ b/src/main/java/it/inaf/oats/vospace/UriService.java
@@ -49,6 +49,9 @@ public class UriService {
     @Value("${file-service-url}")
     private String fileServiceUrl;
 
+    @Value("${link-max-depth}")
+    private int linkMaxDepth;
+
     @Autowired
     private NodeDAO nodeDao;
 
@@ -165,7 +168,7 @@ public class UriService {
         JobService.JobDirection jobType
                 = JobDirection.getJobDirectionEnumFromTransfer(transfer);
         Node node = this.getEndpointNode(relativePath, jobType, user);
-
+               
         switch (jobType) {
             case pushToVoSpace:
             case pullToVoSpace:
@@ -175,6 +178,8 @@ public class UriService {
                 break;
 
             case pullFromVoSpace:
+                // Refresh relative path: it can differ in case of links
+                relativePath = NodeUtils.getVosPath(node);
                 if (!NodeUtils.checkIfReadable(node, creator, groups)) {
                     throw PermissionDeniedException.forPath(relativePath);
                 }
@@ -278,9 +283,25 @@ public class UriService {
     }
 
     private Node followLink(LinkNode linkNode) {
-        String targetPath = URIUtils.returnVosPathFromNodeURI(linkNode.getTarget(), authority);
-        Optional<Node> targetNode = nodeDao.listNode(targetPath);
-        return targetNode.orElseThrow(() -> new InternalFaultException("Broken Link to target: " + targetPath));
+        return this.followLinkRecursive(linkNode, 0);
+    }
 
+    private Node followLinkRecursive(LinkNode linkNode, int depth) {
+                
+        if(depth >= linkMaxDepth) {
+            throw new InternalFaultException("Max link depth reached at link node: " 
+                    + NodeUtils.getVosPath(linkNode));
+        }
+        
+        String targetPath = URIUtils.returnVosPathFromNodeURI(linkNode.getTarget(), authority);
+        
+        Optional<Node> targetNodeOpt = nodeDao.listNode(targetPath);
+        Node targetNode = targetNodeOpt.orElseThrow(() -> new InternalFaultException("Broken Link to target: " + targetPath));
+        
+        if(targetNode instanceof LinkNode) {            
+            return this.followLinkRecursive(linkNode, ++depth);
+        } else {            
+            return targetNode;
+        }
     }
 }
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 924e80d7a965fadee745d05137191ac73b2c1f23..bdc465f9f511ca3fabea5d15cdeca7c533796c1b 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -18,4 +18,7 @@ logging.level.org.springframework.web=TRACE
 
 vospace-authority=example.com!vospace
 
+#tune max depth for chained links
+link-max-depth=10
+
 file-service-url=http://localhost:8087
diff --git a/src/test/java/it/inaf/oats/vospace/TransferControllerTest.java b/src/test/java/it/inaf/oats/vospace/TransferControllerTest.java
index 2a523876e00ffd6e1c23d0bdbf5325c985d2d6e9..81a40c085f9fa7493aa55cc50da13d70e55d80d1 100644
--- a/src/test/java/it/inaf/oats/vospace/TransferControllerTest.java
+++ b/src/test/java/it/inaf/oats/vospace/TransferControllerTest.java
@@ -114,7 +114,7 @@ public class TransferControllerTest {
     @Test
     public void testPullFromVoSpaceSync() throws Exception {
 
-        Node node = mockPublicDataNode();
+        Node node = mockPublicDataNode("vos://example.com!vospace/mynode");
         when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
 
         String requestBody = getResourceFileContent("pullFromVoSpace.xml");
@@ -198,7 +198,7 @@ public class TransferControllerTest {
 
     private void testVoSpaceAsyncTransfer(String path, String requestBody) throws Exception {
 
-        Node node = mockPublicDataNode();
+        Node node = mockPublicDataNode(path);
         when(nodeDao.listNode(eq(path))).thenReturn(Optional.of(node));
 
         String redirect = mockMvc.perform(post("/transfers?PHASE=RUN")
@@ -216,7 +216,7 @@ public class TransferControllerTest {
     @Test
     public void testSetJobPhase() throws Exception {
 
-        Node node = mockPublicDataNode();
+        Node node = mockPublicDataNode("vos://example.com!vospace/mynode");
         when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
 
         JobSummary job = getFakePendingJob();
@@ -268,8 +268,9 @@ public class TransferControllerTest {
         assertEquals("PENDING", phase);
     }
 
-    private Node mockPublicDataNode() {
+    private Node mockPublicDataNode(String nodeURI) {
         Node node = new DataNode();
+        node.setUri(nodeURI);
         Property property = new Property();
         property.setUri("ivo://ivoa.net/vospace/core#publicread");
         property.setValue("true");
@@ -415,7 +416,7 @@ public class TransferControllerTest {
     @Test
     public void testSyncTransferUrlParamsMode() throws Exception {
 
-        Node node = mockPublicDataNode();
+        Node node = mockPublicDataNode("vos://example.com!vospace/mynode");
         when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
 
         mockMvc.perform(get("/synctrans")
diff --git a/src/test/java/it/inaf/oats/vospace/UriServiceTest.java b/src/test/java/it/inaf/oats/vospace/UriServiceTest.java
index c0a911c51012b4cf9ec43ccfc40c0e63969118a1..b14dc36197da4be78525ce9f350dbc2fc8802d9a 100644
--- a/src/test/java/it/inaf/oats/vospace/UriServiceTest.java
+++ b/src/test/java/it/inaf/oats/vospace/UriServiceTest.java
@@ -8,7 +8,9 @@ package it.inaf.oats.vospace;
 import it.inaf.ia2.aa.ServletRapClient;
 import it.inaf.ia2.aa.data.User;
 import it.inaf.oats.vospace.datamodel.NodeProperties;
+import it.inaf.oats.vospace.datamodel.NodeUtils;
 import it.inaf.oats.vospace.datamodel.Views;
+import it.inaf.oats.vospace.exception.InternalFaultException;
 import it.inaf.oats.vospace.exception.InvalidArgumentException;
 import it.inaf.oats.vospace.exception.NodeBusyException;
 import it.inaf.oats.vospace.exception.PermissionDeniedException;
@@ -22,6 +24,7 @@ import javax.servlet.http.HttpServletRequest;
 import net.ivoa.xml.uws.v1.JobSummary;
 import net.ivoa.xml.vospace.v2.ContainerNode;
 import net.ivoa.xml.vospace.v2.DataNode;
+import net.ivoa.xml.vospace.v2.LinkNode;
 import net.ivoa.xml.vospace.v2.Node;
 import net.ivoa.xml.vospace.v2.Param;
 import net.ivoa.xml.vospace.v2.Property;
@@ -101,8 +104,11 @@ public class UriServiceTest {
 
     @Test
     public void testPublicUrl() {
+        
+        String dataUri = "vos://example.com!vospace/mydata1";
 
         Node node = new DataNode();
+        node.setUri(dataUri);
         Property property = new Property();
         property.setUri(NodeProperties.PUBLIC_READ_URI);
         property.setValue("true");
@@ -118,8 +124,11 @@ public class UriServiceTest {
 
     @Test
     public void testPrivateUrl() {
+        
+        String dataUri = "vos://example.com!vospace/mydata1";
 
         Node node = new DataNode();
+        node.setUri(dataUri);
         Property creator = new Property();
         creator.setUri(NodeProperties.CREATOR_URI);
         creator.setValue("user1");
@@ -153,8 +162,11 @@ public class UriServiceTest {
 
     @Test
     public void testPrivateUrlPermissionDenied() {
+        
+        String dataUri = "vos://example.com!vospace/mydata1";
 
         Node node = new DataNode();
+        node.setUri(dataUri);
         Property creator = new Property();
         creator.setUri(NodeProperties.CREATOR_URI);
         creator.setValue("user3");
@@ -188,8 +200,10 @@ public class UriServiceTest {
 
     @Test
     public void testPrivateUrlNodeBusy() {
+        String dataUri = "vos://example.com!vospace/mydata1";
 
         DataNode node = new DataNode();
+        node.setUri(dataUri);
         Property creator = new Property();
         creator.setUri(NodeProperties.CREATOR_URI);
         creator.setValue("user1");
@@ -274,7 +288,115 @@ public class UriServiceTest {
 
         assertEquals("http://file-service/mydata1/mydata2?jobId=job-id2&token=<new-token>", negotiatedTransfer.getProtocols().get(0).getEndpoint());
     }
+    
+    @Test
+    public void pullFromLinkNode() {
+        
+        // URI of pull target node
+        String targetOfPull = "vos://example.com!vospace/mylink1";
+        String targetOfLink = "vos://example.com!vospace/mydummydata1";
+                
+        // Define node properties        
+        Property creator = new Property();
+        creator.setUri(NodeProperties.CREATOR_URI);
+        creator.setValue("user1");
+        
+        Property readgroup = new Property();
+        readgroup.setUri(NodeProperties.GROUP_READ_URI);
+        readgroup.setValue("group1");
+
+        // Define link node as target
+        LinkNode lnode = new LinkNode();
+        lnode.setUri(targetOfPull);
+        lnode.getProperties().add(creator);
+        lnode.getProperties().add(readgroup);        
+        lnode.setTarget(targetOfLink);
+
+        DataNode dnode = new DataNode();
+        dnode.setUri(targetOfLink);
+        dnode.getProperties().add(creator);
+        dnode.getProperties().add(readgroup);
+
+        when(nodeDAO.listNode(eq(NodeUtils.getVosPath(lnode)))).thenReturn(Optional.of(lnode));
+        when(nodeDAO.listNode(eq(NodeUtils.getVosPath(dnode)))).thenReturn(Optional.of(dnode));
+
+        User user = mock(User.class);
+        when(user.getAccessToken()).thenReturn("<token>");
+        when(user.getName()).thenReturn("user1");
+        
+        when(servletRequest.getUserPrincipal()).thenReturn(user);
 
+        
+        when(rapClient.exchangeToken(argThat(req -> {
+            assertEquals("<token>", req.getSubjectToken());
+            assertEquals("http://file-service/mydummydata1", req.getResource());
+            return true;
+        }), any())).thenReturn("<new-token>");
+        
+        JobSummary job = getPullFromVoSpaceJob(targetOfPull);
+        Transfer tr = uriService.getTransfer(job);
+
+        Transfer negotiatedTransfer = uriService.getNegotiatedTransfer(job, tr);
+        assertEquals("http://file-service" 
+                + NodeUtils.getVosPath(dnode) + 
+                "?jobId=job-id-pull&token=<new-token>", 
+                negotiatedTransfer.getProtocols().get(0).getEndpoint());
+    }
+    
+    @Test
+    public void pullFromCircularLinkNode() {
+        
+        // URI of pull target node
+        String targetOfPull = "vos://example.com!vospace/mylink1";
+        String targetOfLink = "vos://example.com!vospace/mylink2";
+                
+        // Define node properties        
+        Property creator = new Property();
+        creator.setUri(NodeProperties.CREATOR_URI);
+        creator.setValue("user1");
+        
+        Property readgroup = new Property();
+        readgroup.setUri(NodeProperties.GROUP_READ_URI);
+        readgroup.setValue("group1");
+
+        // Define link node as target
+        LinkNode lnode = new LinkNode();
+        lnode.setUri(targetOfPull);
+        lnode.getProperties().add(creator);
+        lnode.getProperties().add(readgroup);        
+        lnode.setTarget(targetOfLink);
+
+        LinkNode dnode = new LinkNode();
+        dnode.setUri(targetOfLink);
+        dnode.getProperties().add(creator);
+        dnode.getProperties().add(readgroup);
+        // Circular reference
+        dnode.setTarget(targetOfPull);
+
+        when(nodeDAO.listNode(eq(NodeUtils.getVosPath(lnode)))).thenReturn(Optional.of(lnode));
+        when(nodeDAO.listNode(eq(NodeUtils.getVosPath(dnode)))).thenReturn(Optional.of(dnode));
+
+        User user = mock(User.class);
+        when(user.getAccessToken()).thenReturn("<token>");
+        when(user.getName()).thenReturn("user1");
+        
+        when(servletRequest.getUserPrincipal()).thenReturn(user);
+
+        
+        when(rapClient.exchangeToken(argThat(req -> {
+            assertEquals("<token>", req.getSubjectToken());
+            assertEquals("http://file-service/mydummydata1", req.getResource());
+            return true;
+        }), any())).thenReturn("<new-token>");
+        
+        JobSummary job = getPullFromVoSpaceJob(targetOfPull);
+        Transfer tr = uriService.getTransfer(job);
+        
+        assertThrows(InternalFaultException.class, () -> {
+            uriService.getNegotiatedTransfer(job, tr);
+        });
+    }
+    
     @Test
     public void setNodeRemoteLocationTest() {
 
@@ -526,4 +648,23 @@ public class UriServiceTest {
 
         return job;
     }
+    
+    private JobSummary getPullFromVoSpaceJob(String target) {
+        Transfer transfer = new Transfer();
+        transfer.setTarget(target);
+        transfer.setDirection(JobService.JobDirection.pullFromVoSpace.toString());
+        Protocol protocol = new Protocol();
+        protocol.setUri("ivo://ivoa.net/vospace/core#httpget");
+        transfer.getProtocols().add(protocol);
+
+        JobSummary job = new JobSummary();
+        job.setJobId("job-id-pull");
+
+        JobSummary.JobInfo jobInfo = new JobSummary.JobInfo();
+        jobInfo.getAny().add(transfer);
+
+        job.setJobInfo(jobInfo);
+
+        return job;
+    }
 }