diff --git a/src/client/nexus-exam/nexus-exam.go b/src/client/nexus-exam/nexus-exam.go
index 4da49b36ed7315ea1f585dd4be9a618c0bdb6f67..846d19a97b6a7a7c15a03141fc0081cbe7e63d9c 100644
--- a/src/client/nexus-exam/nexus-exam.go
+++ b/src/client/nexus-exam/nexus-exam.go
@@ -2,6 +2,7 @@ package main
 
 import (
     "os"
+    "time"
     "bytes"
     "errors"
     "strings"
@@ -13,14 +14,12 @@ import (
     e "nexus-client/exec"
     u "nexus-client/utils"
     g "nexus-client/globals"
-    // "nexus-client/cmdVersion"
     "nexus-client/defaults"
     "nexus-client/cmdLogin"
     "fyne.io/fyne/v2"
     "fyne.io/fyne/v2/app"
     "fyne.io/fyne/v2/theme"
     "fyne.io/fyne/v2/widget"
-    "fyne.io/fyne/v2/dialog"
     "fyne.io/fyne/v2/container"
     "github.com/go-resty/resty/v2"
     "github.com/go-playground/validator/v10"
@@ -37,6 +36,7 @@ var (
     //go:embed nexus_exam_pwd.val
     nexus_exam_pwd string
 
+    token string
     certPath string
     exitFn func()
 )
@@ -48,35 +48,32 @@ func exit(code int) {
     os.Exit(code)
 }
 
-func errorDialog(parent fyne.Window, msg string) {
-    dialog.NewInformation("Connection error", msg, parent).Show()
-}
-
-func attachVM(parent fyne.Window, token, hostname, cert, pwd string) (int, error) {
+func attachVM(parent fyne.Window, hostname, cert, pwd string) {
     client := g.GetInstance().Client
     host := g.GetInstance().Host
 
-    client.SetHeader("Content-Type", "application/json")
-    client.SetHeader("Authorization", "Bearer "+token)
-
     p := &params.VMAttachCreds{ Pwd: pwd }
     resp, err := client.R().SetBody(p).Post(host+"/vms/spicecreds")
     if err != nil {
-        errorDialog(parent, err.Error())
+        errorPopup(parent, "Failed attaching to VM (code 4)")
+        return
     }
 
     if resp.IsSuccess() {
         var creds vm.VMSpiceCredentialsSerialized
         if err := json.Unmarshal(resp.Body(), &creds); err != nil {
-            errorDialog(parent, err.Error())
+            errorPopup(parent, "Failed attaching to VM (code 5)")
+            return
         }
         if err := validator.New(validator.WithRequiredStructEnabled()).Struct(creds); err != nil {
-            errorDialog(parent, err.Error())
+            errorPopup(parent, "Failed attaching to VM (code 6)")
+            return
         }
         go func(creds vm.VMSpiceCredentialsSerialized) {
             _, err := e.RunRemoteViewer(hostname, cert, creds.Name, creds.SpicePort, creds.SpicePwd, true)
             if err != nil {
-                errorDialog(parent, err.Error())
+                errorPopup(parent, "Failed attaching to VM (code 7)")
+                return
             }
         } (creds)
     } else {
@@ -85,15 +82,16 @@ func attachVM(parent fyne.Window, token, hostname, cert, pwd string) (int, error
         }
         var m msg
         if err := json.Unmarshal(resp.Body(), &m); err != nil {
-            errorDialog(parent, err.Error())
+            errorPopup(parent, "Failed attaching to VM (code 8)")
+            return
         }
-        errorDialog(parent, m.Message)
-        // errorDialog(parent, "Error: "+resp.Status()+": "+resp.String())
+        errorPopup(parent, m.Message)
+        // errorPopup(parent, "Error: "+resp.Status()+": "+resp.String())
+        return
     }
-    return 0, nil
 }
 
-func abortWindow(labelMsg string) {
+func abortWindow(msg string) {
 	a := app.New()
     a.Settings().SetTheme(theme.LightTheme())
 	win := a.NewWindow(windowTitle)
@@ -101,8 +99,8 @@ func abortWindow(labelMsg string) {
         win.Close()
 		exit(1)
     })
-	label := widget.NewLabel("ERROR: "+labelMsg)
-	button := widget.NewButton("OK", func() {
+	label := widget.NewLabel("FATAL: "+msg)
+	button := widget.NewButton("Quit", func() {
         win.Close()
 		exit(1)
 	})
@@ -111,6 +109,16 @@ func abortWindow(labelMsg string) {
     win.ShowAndRun()
 }
 
+func errorPopup(win fyne.Window, msg string) {
+	var modal *widget.PopUp
+	modal = widget.NewModalPopUp(
+		container.NewVBox(
+			widget.NewLabel("Error: "+msg),
+			widget.NewButton("Close", func() { modal.Hide() })),
+		win.Canvas())
+	modal.Show()
+}
+
 func hypervisorCheck() {
     cmd := exec.Command("systemd-detect-virt")
     var out bytes.Buffer
@@ -122,6 +130,35 @@ func hypervisorCheck() {
     }
 }
 
+// Recurrently obtains a new JWT token so that the user session doesn't expire.
+func refreshToken(parent fyne.Window) {
+    client := g.GetInstance().Client
+    host := g.GetInstance().Host
+
+    for {
+        resp, err := client.R().Get(host+"/token/refresh")
+        if err != nil {
+            errorPopup(parent, "Failed refreshing token (code 1)")
+        } else {
+            if resp.IsSuccess() {
+                type Response struct {
+                    Token string
+                }
+                var response Response
+                err = json.Unmarshal(resp.Body(), &response)
+                if err != nil {
+                    errorPopup(parent, "Failed refreshing token (code 2)")
+                }
+                token = response.Token
+            } else {
+                // errorPopup(parent, resp.Status()+": "+resp.String())
+                errorPopup(parent, "Failed refreshing token (code 3)")
+            }
+        }
+        time.Sleep(4*time.Hour)
+    }
+}
+
 func run() int {
     hypervisorCheck()
 
@@ -166,17 +203,22 @@ func run() int {
 
     g.Init(hostname, host, certPath, client)
 
+    client.SetTimeout(10*time.Second)
+
     // Checks the client version is compatible with the server's API.
     // if !cmdVersion.CheckServerCompatibility("nexus-exam") {
     //     abortWindow("client version is incompatible with server!")
     // }
 
     // Logins and obtains a JWT token.
-    token, err := cmdLogin.GetToken(nexus_exam_user, nexus_exam_pwd)
+    token, err = cmdLogin.GetToken(nexus_exam_user, nexus_exam_pwd)
     if err != nil {
-        abortWindow(err.Error())
+        abortWindow("Failed obtaining token (network issue?)")
     }
 
+    client.SetHeader("Content-Type", "application/json")
+    client.SetHeader("Authorization", "Bearer "+token)
+
     app := app.New()
     app.Settings().SetTheme(theme.LightTheme())
     win := app.NewWindow(windowTitle)
@@ -198,13 +240,16 @@ func run() int {
             {Text: "Password", Widget: pwdEntry},
         },
         OnSubmit: func() {
-            attachVM(win, token, hostname, certPath, pwdEntry.Text)
+            attachVM(win, hostname, certPath, pwdEntry.Text)
         },
         SubmitText: "Connect",
     }
 
     win.SetContent(container.NewPadded(container.NewVBox(label, form)))
     win.Resize(fyne.NewSize(600,200))
+
+    go refreshToken(win)
+
     win.ShowAndRun()
     return 0
 }
diff --git a/src/server/router/auth.go b/src/server/router/auth.go
index 465ac426170b206f00f575a04ebed226718449af..c142fd2e404ca81597ea092448269156aae8ea2b 100644
--- a/src/server/router/auth.go
+++ b/src/server/router/auth.go
@@ -50,7 +50,7 @@ func NewAuth(u *users.Users) (*auth, error) {
             SigningKey: []byte(jwtSecretKey),
             Claims:     &customClaims{},
             ErrorHandlerWithContext: func(err error, c echo.Context) error {
-                return echo.NewHTTPError(http.StatusUnauthorized, "access denied")
+                return echo.NewHTTPError(http.StatusUnauthorized, "token expired: access denied")
             },
         },
     }, nil