diff --git a/backend/src/baserow/api/v0/jwt.py b/backend/src/baserow/api/v0/jwt.py
deleted file mode 100644
index fa8e89522..000000000
--- a/backend/src/baserow/api/v0/jwt.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .serializers.user import UserSerializer
-
-
-def jwt_response_payload_handler(token, user=None, request=None):
-    return {
-        'token': token,
-        'data': UserSerializer(user, context={'request': request}).data
-    }
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 707d4e08e..e8ee59640 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -129,8 +129,7 @@ CORS_ORIGIN_WHITELIST = (
 )
 
 JWT_AUTH = {
-    'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300),
+    'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=30),
     'JWT_ALLOW_REFRESH': True,
-    'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
-    'JWT_RESPONSE_PAYLOAD_HANDLER': 'baserow.api.v0.jwt.jwt_response_payload_handler'
+    'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7)
 }
diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js
index ddaa1e6a8..8cc9e28cd 100644
--- a/web-frontend/config/nuxt.config.base.js
+++ b/web-frontend/config/nuxt.config.base.js
@@ -30,24 +30,14 @@ export default {
   /*
    ** Nuxt.js modules
    */
-  modules: [
-    // Doc: https://axios.nuxtjs.org/usage
-    '@nuxtjs/axios'
-  ],
+  modules: ['@nuxtjs/axios', 'cookie-universal-nuxt'],
 
-  /*
-   ** Axios module configuration
-   */
-  axios: {
-    // See https://github.com/nuxt-community/axios-module#options
+  router: {
+    middleware: 'authentication'
   },
 
   env: {
     // The API base url, this will be prepended to the urls of the remote calls.
-    baseUrl: 'http://localhost:8000',
-
-    // The JWT token expire time in seconds, when this time passes after a login
-    // or refresh, the token will be refreshed.
-    JWTTokenExpire: 300
+    baseUrl: 'http://localhost:8000'
   }
 }
diff --git a/web-frontend/middleware/.gitkeep b/web-frontend/middleware/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/web-frontend/middleware/authenticated.js b/web-frontend/middleware/authenticated.js
new file mode 100644
index 000000000..7dd268242
--- /dev/null
+++ b/web-frontend/middleware/authenticated.js
@@ -0,0 +1,13 @@
+/**
+ * If this middleware is added to a page, it will redirect back to the login
+ * page if the user is not authenticated.
+ */
+export default function({ req, store, redirect }) {
+  // If nuxt generate, pass this middleware
+  if (process.server && !req) return
+
+  // If the user is not authenticated we will redirect him to the login page.
+  if (!store.getters['auth/isAuthenticated']) {
+    redirect('/login')
+  }
+}
diff --git a/web-frontend/middleware/authentication.js b/web-frontend/middleware/authentication.js
new file mode 100644
index 000000000..7506dfbed
--- /dev/null
+++ b/web-frontend/middleware/authentication.js
@@ -0,0 +1,16 @@
+import { getToken } from '@/utils/auth'
+
+export default function({ store, req, app }) {
+  // If nuxt generate, pass this middleware
+  if (process.server && !req) return
+
+  // Load the token
+  const token = getToken(app.$cookies)
+
+  // If there already is a token we will refresh it to check if it is valid and
+  // to get fresh user information. This will probably happen on the server
+  // side.
+  if (token && !store.getters['auth/isAuthenticated']) {
+    return store.dispatch('auth/refresh', token)
+  }
+}
diff --git a/web-frontend/package.json b/web-frontend/package.json
index e18578437..1356f7334 100644
--- a/web-frontend/package.json
+++ b/web-frontend/package.json
@@ -15,8 +15,10 @@
   },
   "dependencies": {
     "@fortawesome/fontawesome-free": "^5.8.2",
-    "@nuxtjs/axios": "^5.3.6",
+    "@nuxtjs/axios": "^5.5.4",
+    "cookie-universal-nuxt": "^2.0.16",
     "cross-env": "^5.2.0",
+    "jwt-decode": "^2.2.0",
     "lodash.merge": "^4.6.1",
     "node-sass": "^4.12.0",
     "normalize-scss": "^7.0.1",
diff --git a/web-frontend/pages/index.vue b/web-frontend/pages/index.vue
index adbd0f460..db76e6c69 100644
--- a/web-frontend/pages/index.vue
+++ b/web-frontend/pages/index.vue
@@ -1,5 +1,16 @@
 <template>
   <div>
     <h1>Baserow</h1>
+    <p>authenticated: {{ isAuthenticated }}</p>
+    <nuxt-link :to="{ name: 'login' }">Login</nuxt-link>
   </div>
 </template>
+
+<script>
+import { mapGetters } from 'vuex'
+
+export default {
+  middleware: 'authenticated',
+  computed: { ...mapGetters({ isAuthenticated: 'auth/isAuthenticated' }) }
+}
+</script>
diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue
index 251333f62..38233fabc 100644
--- a/web-frontend/pages/login/index.vue
+++ b/web-frontend/pages/login/index.vue
@@ -12,7 +12,6 @@
         The provided e-mail address or password is incorrect.
       </p>
     </div>
-    authenticated: {{ loggedIn }}
     <form @submit.prevent="login">
       <div class="control">
         <label class="control-label">E-mail address</label>
@@ -53,6 +52,11 @@
               Sign up
             </nuxt-link>
           </li>
+          <li>
+            <nuxt-link :to="{ name: 'index' }">
+              Index
+            </nuxt-link>
+          </li>
         </ul>
         <button
           :class="{ 'button-loading': loading }"
@@ -67,7 +71,6 @@
 </template>
 
 <script>
-import { mapGetters } from 'vuex'
 import { required, email } from 'vuelidate/lib/validators'
 
 export default {
@@ -87,7 +90,6 @@ export default {
       }
     }
   },
-  computed: { ...mapGetters({ loggedIn: 'auth/loggedIn' }) },
   validations: {
     credentials: {
       email: { required, email },
diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js
index 8577adaa8..7ee29cba2 100644
--- a/web-frontend/plugins/auth.js
+++ b/web-frontend/plugins/auth.js
@@ -1,11 +1,24 @@
-export default function({ store }) {
-  if (!process.browser) {
-    return
-  }
+import { client } from '@/services/client'
 
-  const user = JSON.parse(localStorage.getItem('user'))
-  if (user) {
-    store.commit('auth/SET_USER_DATA', user)
-    store.dispatch('auth/refresh')
+export default function({ store }) {
+  // Create a request interceptor to add the authorization token to every
+  // request if the user is authenticated.
+  client.interceptors.request.use(config => {
+    if (store.getters['auth/isAuthenticated']) {
+      const token = store.getters['auth/token']
+      config.headers.Authorization = `JWT: ${token}`
+    }
+    return config
+  })
+
+  // If the user is authenticated, but is not refreshing in the browser means
+  // that the refresh was done on the server side, so we need to manually start
+  // the refreshing timeout here.
+  if (
+    store.getters['auth/isAuthenticated'] &&
+    !store.getters['auth/isRefreshing'] &&
+    process.browser
+  ) {
+    store.dispatch('auth/startRefreshTimeout')
   }
 }
diff --git a/web-frontend/services/authService.js b/web-frontend/services/auth.js
similarity index 100%
rename from web-frontend/services/authService.js
rename to web-frontend/services/auth.js
diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js
index 010765bc6..5d6def1d0 100644
--- a/web-frontend/store/auth.js
+++ b/web-frontend/store/auth.js
@@ -1,56 +1,98 @@
-import AuthService from '@/services/authService.js'
-import { client } from '@/services/client.js'
+import jwtDecode from 'jwt-decode'
+
+import AuthService from '@/services/auth'
+import { setToken, unsetToken } from '@/utils/auth'
 
 export const state = () => ({
+  refreshing: false,
+  token: null,
   user: null
 })
 
 export const mutations = {
-  SET_USER_DATA(state, data) {
-    state.user = data
-    localStorage.setItem('user', JSON.stringify(data))
-    client.defaults.headers.common.Authorization = `JWT ${data.token}`
+  SET_USER_DATA(state, token) {
+    state.token = token
+    state.user = jwtDecode(token)
   },
   CLEAR_USER_DATA(state) {
+    state.token = null
     state.user = null
-    localStorage.removeItem('user')
-    client.defaults.headers.common.pop('Authorization')
+  },
+  SET_REFRESHING(state, refreshing) {
+    state.refreshing = refreshing
   }
 }
 
 export const actions = {
+  /**
+   * Authenticate a user by his email and password. If successful commit the
+   * token to the state and start the refresh timeout to stay authenticated.
+   */
   login({ commit, dispatch }, { email, password }) {
     return AuthService.login(email, password).then(({ data }) => {
-      commit('SET_USER_DATA', data)
+      setToken(data.token, this.app.$cookies)
+      commit('SET_USER_DATA', data.token)
       dispatch('startRefreshTimeout')
     })
   },
-  refresh({ commit, state, dispatch }) {
-    return AuthService.refresh(state.user.token)
+  /**
+   * Refresh the existing token. If successful commit the new token and start a
+   * new refresh timeout. If unsuccessful the existing cookie and user data is
+   * cleared.
+   */
+  refresh({ commit, state, dispatch }, token) {
+    return AuthService.refresh(token)
       .then(({ data }) => {
-        commit('SET_USER_DATA', data)
+        setToken(data.token, this.app.$cookies)
+        commit('SET_USER_DATA', data.token)
         dispatch('startRefreshTimeout')
       })
       .catch(() => {
         // The token could not be refreshed, this means the token is no longer
         // valid and the user not logged in anymore.
+        unsetToken(this.app.$cookies)
         commit('CLEAR_USER_DATA')
+
+        // @TODO we might want to do something here, trigger some event, show
+        //       show the user a login popup or redirect to the login page.
       })
   },
   /**
    * Because the token expires within a configurable time, we need to keep
-   * refreshing the token before that happens.
+   * refreshing the token before that happens. This process may only happen in
+   * the browser because that is where we measure if the user still has the
+   * application open.
    */
-  startRefreshTimeout({ dispatch }) {
+  startRefreshTimeout({ getters, commit, dispatch }) {
+    if (!process.browser) return
+
     clearTimeout(this.refreshTimeout)
+    commit('SET_REFRESHING', true)
+
     this.refreshTimeout = setTimeout(() => {
-      dispatch('refresh')
-    }, (process.env.JWTTokenExpire - 2) * 1000)
+      dispatch('refresh', getters.token)
+      commit('SET_REFRESHING', false)
+    }, (getters.tokenExpireSeconds - 10) * 1000)
   }
 }
 
 export const getters = {
-  loggedIn(state) {
+  isAuthenticated(state) {
     return !!state.user
+  },
+  isRefreshing(state) {
+    return state.refresh
+  },
+  token(state) {
+    return state.token
+  },
+  /**
+   * Returns the amount of seconds it will take before the tokes expires.
+   * @TODO figure out what happens if the browser and server time and not very
+   *       much in sync.
+   */
+  tokenExpireSeconds(state) {
+    const now = Math.ceil(new Date().getTime() / 1000)
+    return state.user.exp - now
   }
 }
diff --git a/web-frontend/utils/auth.js b/web-frontend/utils/auth.js
new file mode 100644
index 000000000..f8aba08f4
--- /dev/null
+++ b/web-frontend/utils/auth.js
@@ -0,0 +1,15 @@
+const cookieTokenName = 'jwt_token'
+
+export const setToken = (token, cookie) => {
+  if (process.SERVER_BUILD) return
+  cookie.set(cookieTokenName, token)
+}
+
+export const unsetToken = cookie => {
+  if (process.SERVER_BUILD) return
+  cookie.remove(cookieTokenName)
+}
+
+export const getToken = cookie => {
+  return cookie.get(cookieTokenName)
+}
diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock
index 191230b1e..20fb13959 100644
--- a/web-frontend/yarn.lock
+++ b/web-frontend/yarn.lock
@@ -1102,15 +1102,15 @@
     webpack-node-externals "^1.7.2"
     webpackbar "^3.2.0"
 
-"@nuxtjs/axios@^5.3.6":
-  version "5.5.2"
-  resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.2.tgz#b6447bb12707b56b16b942ae6c737a0b051cecba"
-  integrity sha512-S5+IkUjCSSFeMVZp/JAzjoit9P7Di2QM4beAlFbHXbOEG+/vSaZReW8l817u9WC6nuKa3x6HhZfWD3tJDTvljg==
+"@nuxtjs/axios@^5.5.4":
+  version "5.5.4"
+  resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.4.tgz#c4aee2322901b19d4072670c03144662a73ea6f4"
+  integrity sha512-/Ljsyh5VIc9paXGrQue7RQ+PpBNES1oit0g4l+ya1tfyKnZMpHSbghuLcv0xq+BpXlSEr690uemHbz54/N6U5w==
   dependencies:
     "@nuxtjs/proxy" "^1.3.3"
-    axios "^0.18.0"
+    axios "^0.19.0"
     axios-retry "^3.1.2"
-    consola "^2.6.2"
+    consola "^2.7.1"
 
 "@nuxtjs/eslint-config@^0.0.1":
   version "0.0.1"
@@ -1167,6 +1167,11 @@
   dependencies:
     "@babel/types" "^7.3.0"
 
+"@types/cookie@^0.3.1":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
+  integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
+
 "@types/events@*":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
@@ -1869,13 +1874,13 @@ axios-retry@^3.1.2:
   dependencies:
     is-retry-allowed "^1.1.0"
 
-axios@^0.18.0:
-  version "0.18.0"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102"
-  integrity sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=
+axios@^0.19.0:
+  version "0.19.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8"
+  integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==
   dependencies:
-    follow-redirects "^1.3.0"
-    is-buffer "^1.1.5"
+    follow-redirects "1.5.10"
+    is-buffer "^2.0.2"
 
 babel-code-frame@^6.26.0:
   version "6.26.0"
@@ -2845,11 +2850,16 @@ connect@^3.6.6:
     parseurl "~1.3.3"
     utils-merge "1.0.1"
 
-consola@^2.3.0, consola@^2.5.6, consola@^2.6.0, consola@^2.6.1, consola@^2.6.2:
+consola@^2.3.0, consola@^2.5.6, consola@^2.6.0, consola@^2.6.1:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/consola/-/consola-2.7.1.tgz#3f7f7c53eb44362240c3aee41b9bb2641d5ca32e"
   integrity sha512-u7JYs+HnMbZPD2cEuS1XHsLeqtazA0kd5lAk8r8DnnGdgNhOdb7DSubJ+QLdQkbtpmmxgp7gs8Ug44sCyY4FCQ==
 
+consola@^2.7.1:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/consola/-/consola-2.9.0.tgz#57760e3a65a53ec27337f4add31505802d902278"
+  integrity sha512-34Iue+LRcWbndFIfZc5boNizWlsrRjqIBJZTe591vImgbnq7nx2EzlrLtANj9TH2Fxm7puFJBJAOk5BhvZOddQ==
+
 console-browserify@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
@@ -2903,6 +2913,22 @@ cookie-signature@1.0.6:
   resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
   integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
 
+cookie-universal-nuxt@^2.0.16:
+  version "2.0.16"
+  resolved "https://registry.yarnpkg.com/cookie-universal-nuxt/-/cookie-universal-nuxt-2.0.16.tgz#8d528098c973162b352199240e40da0e5429b13f"
+  integrity sha512-wRK2zw8w+a5xPehb5kLbgOic/4mbjl2exUCxWZwGuttcwsFgOymiwDrCOzmQslqrDevPDL2SsBbH6wtOm7dB9g==
+  dependencies:
+    "@types/cookie" "^0.3.1"
+    cookie-universal "^2.0.16"
+
+cookie-universal@^2.0.16:
+  version "2.0.16"
+  resolved "https://registry.yarnpkg.com/cookie-universal/-/cookie-universal-2.0.16.tgz#ec8b55789b502a377ef02ad230923c1dfa5c1061"
+  integrity sha512-EHtQ5Tg3UoUHG7LmeV3rlV3iYthkhUuYZ0y86EseypxGcUuvzxuHExEb6mHKDhDPrIrdewAHdG/aCHuG/T4zEg==
+  dependencies:
+    "@types/cookie" "^0.3.1"
+    cookie "^0.3.1"
+
 cookie@0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
@@ -3340,6 +3366,13 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
+debug@=3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
 debug@^3.1.0, debug@^3.2.6:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
@@ -4378,7 +4411,14 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.3"
     readable-stream "^2.3.6"
 
-follow-redirects@^1.0.0, follow-redirects@^1.3.0:
+follow-redirects@1.5.10:
+  version "1.5.10"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
+  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
+  dependencies:
+    debug "=3.1.0"
+
+follow-redirects@^1.0.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76"
   integrity sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==
@@ -5260,7 +5300,7 @@ is-buffer@^1.1.5:
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
-is-buffer@^2.0.0:
+is-buffer@^2.0.0, is-buffer@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725"
   integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==
@@ -6114,6 +6154,11 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
+jwt-decode@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
+  integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"