diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 50b7f9e18..a882c87c4 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -1,4 +1,5 @@
 import os
+import datetime
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -21,10 +22,12 @@ INSTALLED_APPS = [
     'django.contrib.messages',
     'django.contrib.staticfiles',
 
-    'rest_framework'
+    'rest_framework',
+    'corsheaders'
 ]
 
 MIDDLEWARE = [
+    'corsheaders.middleware.CorsMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
@@ -118,3 +121,13 @@ REST_FRAMEWORK = {
         'rest_framework.authentication.BasicAuthentication',
     ),
 }
+
+CORS_ORIGIN_WHITELIST = (
+    'http://localhost:3000',
+)
+
+JWT_AUTH = {
+    'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300),
+    'JWT_ALLOW_REFRESH': True,
+    'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
+}
diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py
index 5e19a2143..f1b168c1b 100644
--- a/backend/src/baserow/config/urls.py
+++ b/backend/src/baserow/config/urls.py
@@ -2,7 +2,7 @@ from django.urls import path, include
 from django.conf.urls import url
 
 from rest_framework import routers
-from rest_framework_jwt.views import obtain_jwt_token
+from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token
 
 
 router = routers.DefaultRouter()
@@ -10,5 +10,6 @@ router = routers.DefaultRouter()
 
 urlpatterns = [
     url(r'^api/token-auth/', obtain_jwt_token),
+    url(r'^api/token-refresh/', refresh_jwt_token),
     path('api/', include(router.urls)),
 ]
diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js
index 61186e868..ddaa1e6a8 100644
--- a/web-frontend/config/nuxt.config.base.js
+++ b/web-frontend/config/nuxt.config.base.js
@@ -25,7 +25,7 @@ export default {
   /*
    ** Plugins to load before mounting the App
    */
-  plugins: [{ src: '@/plugins/Vuelidate.js' }],
+  plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }],
 
   /*
    ** Nuxt.js modules
@@ -40,5 +40,14 @@ export default {
    */
   axios: {
     // See https://github.com/nuxt-community/axios-module#options
+  },
+
+  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
   }
 }
diff --git a/web-frontend/config/nuxt.config.dev.js b/web-frontend/config/nuxt.config.dev.js
index f4e4738dc..ad09b0507 100644
--- a/web-frontend/config/nuxt.config.dev.js
+++ b/web-frontend/config/nuxt.config.dev.js
@@ -6,7 +6,6 @@ import base from './nuxt.config.base.js'
 const config = {
   build: {
     extend(config, ctx) {
-      // Run ESLint ad Stylelint on save
       if (ctx.isDev && ctx.isClient) {
         config.module.rules.push({
           enforce: 'pre',
diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue
index db3c47e46..251333f62 100644
--- a/web-frontend/pages/login/index.vue
+++ b/web-frontend/pages/login/index.vue
@@ -3,14 +3,25 @@
     <h1 class="box-title">
       <img src="@/static/img/logo.svg" alt="" />
     </h1>
+    <div v-if="invalid" class="alert alert-error alert-has-icon">
+      <div class="alert-icon">
+        <i class="fas fa-exclamation"></i>
+      </div>
+      <div class="alert-title">Incorrect credentials</div>
+      <p class="alert-content">
+        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>
         <div class="control-elements">
           <input
+            ref="email"
             v-model="credentials.email"
             :class="{ 'input-error': $v.credentials.email.$error }"
-            type="text"
+            type="email"
             class="input input-large"
             @blur="$v.credentials.email.$touch()"
           />
@@ -23,6 +34,7 @@
         <label class="control-label">Password</label>
         <div class="control-elements">
           <input
+            ref="password"
             v-model="credentials.password"
             :class="{ 'input-error': $v.credentials.password.$error }"
             type="password"
@@ -55,6 +67,7 @@
 </template>
 
 <script>
+import { mapGetters } from 'vuex'
 import { required, email } from 'vuelidate/lib/validators'
 
 export default {
@@ -67,12 +80,14 @@ export default {
   data() {
     return {
       loading: false,
+      invalid: false,
       credentials: {
         email: '',
         password: ''
       }
     }
   },
+  computed: { ...mapGetters({ loggedIn: 'auth/loggedIn' }) },
   validations: {
     credentials: {
       email: { required, email },
@@ -84,6 +99,23 @@ export default {
       this.$v.$touch()
       if (!this.$v.$invalid) {
         this.loading = true
+        this.$store
+          .dispatch('auth/login', {
+            email: this.credentials.email,
+            password: this.credentials.password
+          })
+          .then(() => {
+            console.log('@TODO navigate to main page')
+          })
+          .catch(() => {
+            this.invalid = true
+            this.credentials.password = ''
+            this.$v.$reset()
+            this.$refs.password.focus()
+          })
+          .then(() => {
+            this.loading = false
+          })
       }
     }
   }
diff --git a/web-frontend/plugins/.gitkeep b/web-frontend/plugins/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js
new file mode 100644
index 000000000..8577adaa8
--- /dev/null
+++ b/web-frontend/plugins/auth.js
@@ -0,0 +1,11 @@
+export default function({ store }) {
+  if (!process.browser) {
+    return
+  }
+
+  const user = JSON.parse(localStorage.getItem('user'))
+  if (user) {
+    store.commit('auth/SET_USER_DATA', user)
+    store.dispatch('auth/refresh')
+  }
+}
diff --git a/web-frontend/plugins/Vuelidate.js b/web-frontend/plugins/vuelidate.js
similarity index 100%
rename from web-frontend/plugins/Vuelidate.js
rename to web-frontend/plugins/vuelidate.js
diff --git a/web-frontend/services/authService.js b/web-frontend/services/authService.js
new file mode 100644
index 000000000..b5f6b098f
--- /dev/null
+++ b/web-frontend/services/authService.js
@@ -0,0 +1,15 @@
+import { client } from './client'
+
+export default {
+  login(username, password) {
+    return client.post('/api/token-auth/', {
+      username: username,
+      password: password
+    })
+  },
+  refresh(token) {
+    return client.post('/api/token-refresh/', {
+      token: token
+    })
+  }
+}
diff --git a/web-frontend/services/client.js b/web-frontend/services/client.js
new file mode 100644
index 000000000..2315c1f32
--- /dev/null
+++ b/web-frontend/services/client.js
@@ -0,0 +1,10 @@
+import axios from 'axios'
+
+export const client = axios.create({
+  baseURL: process.env.baseUrl,
+  withCredentials: false,
+  headers: {
+    Accept: 'application/json',
+    'Content-Type': 'application/json'
+  }
+})
diff --git a/web-frontend/store/.gitkeep b/web-frontend/store/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js
new file mode 100644
index 000000000..010765bc6
--- /dev/null
+++ b/web-frontend/store/auth.js
@@ -0,0 +1,56 @@
+import AuthService from '@/services/authService.js'
+import { client } from '@/services/client.js'
+
+export const state = () => ({
+  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}`
+  },
+  CLEAR_USER_DATA(state) {
+    state.user = null
+    localStorage.removeItem('user')
+    client.defaults.headers.common.pop('Authorization')
+  }
+}
+
+export const actions = {
+  login({ commit, dispatch }, { email, password }) {
+    return AuthService.login(email, password).then(({ data }) => {
+      commit('SET_USER_DATA', data)
+      dispatch('startRefreshTimeout')
+    })
+  },
+  refresh({ commit, state, dispatch }) {
+    return AuthService.refresh(state.user.token)
+      .then(({ data }) => {
+        commit('SET_USER_DATA', data)
+        dispatch('startRefreshTimeout')
+      })
+      .catch(() => {
+        // The token could not be refreshed, this means the token is no longer
+        // valid and the user not logged in anymore.
+        commit('CLEAR_USER_DATA')
+      })
+  },
+  /**
+   * Because the token expires within a configurable time, we need to keep
+   * refreshing the token before that happens.
+   */
+  startRefreshTimeout({ dispatch }) {
+    clearTimeout(this.refreshTimeout)
+    this.refreshTimeout = setTimeout(() => {
+      dispatch('refresh')
+    }, (process.env.JWTTokenExpire - 2) * 1000)
+  }
+}
+
+export const getters = {
+  loggedIn(state) {
+    return !!state.user
+  }
+}