diff --git a/go.mod b/go.mod
index 40066a259..81e14e6fc 100644
--- a/go.mod
+++ b/go.mod
@@ -8,11 +8,13 @@ require (
 	github.com/astaxie/beego v1.12.0
 	github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
 	github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
+	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
 	github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
 	github.com/fatih/structs v1.0.0 // indirect
 	github.com/go-chi/chi v4.0.3+incompatible
 	github.com/go-chi/cors v1.0.0
+	github.com/go-chi/jwtauth v4.0.3+incompatible
 	github.com/google/uuid v1.1.1
 	github.com/google/wire v0.4.0
 	github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
diff --git a/go.sum b/go.sum
index b9fc00abd..94fd0377e 100644
--- a/go.sum
+++ b/go.sum
@@ -24,6 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
 github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
 github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
 github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
@@ -39,6 +41,8 @@ github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8q
 github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
 github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
 github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
+github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
+github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
 github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
diff --git a/model/user.go b/model/user.go
index f2e2ab2bc..f59e8195e 100644
--- a/model/user.go
+++ b/model/user.go
@@ -4,7 +4,9 @@ import "time"
 
 type User struct {
 	ID           string
+	UserName     string
 	Name         string
+	Email        string
 	Password     string
 	IsAdmin      bool
 	LastLoginAt  *time.Time
@@ -17,4 +19,6 @@ type UserRepository interface {
 	CountAll(...QueryOptions) (int64, error)
 	Get(id string) (*User, error)
 	Put(*User) error
+	FindByUsername(username string) (*User, error)
+	UpdateLastLoginAt(id string) error
 }
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index 71e736c52..e2d8e1062 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -10,7 +10,9 @@ import (
 
 type user struct {
 	ID           string     `json:"id"             orm:"pk;column(id)"`
-	Name         string     `json:"name"           orm:"index"`
+	UserName     string     `json:"userName"       orm:"index;unique"`
+	Name         string     `json:"name"`
+	Email        string     `json:"email"          orm:"unique"`
 	Password     string     `json:"password"`
 	IsAdmin      bool       `json:"isAdmin"`
 	LastLoginAt  *time.Time `json:"lastLoginAt"    orm:"null"`
@@ -24,6 +26,12 @@ type userRepository struct {
 	userResource model.ResourceRepository
 }
 
+func NewUserRepository(o orm.Ormer) model.UserRepository {
+	r := &userRepository{ormer: o}
+	r.userResource = NewResource(o, model.User{}, new(user))
+	return r
+}
+
 func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
 	if len(qo) > 0 {
 		return r.userResource.Count(rest.QueryOptions(qo[0]))
@@ -41,22 +49,36 @@ func (r *userRepository) Get(id string) (*model.User, error) {
 }
 
 func (r *userRepository) Put(u *model.User) error {
+	tu := user(*u)
 	c, err := r.CountAll()
 	if err != nil {
 		return err
 	}
 	if c == 0 {
-		mappedUser := user(*u)
-		_, err = r.userResource.Save(&mappedUser)
+		_, err = r.userResource.Save(&tu)
 		return err
 	}
-	return r.userResource.Update(u, "name", "is_admin", "password")
+	return r.userResource.Update(&tu, "user_name", "is_admin", "password")
 }
 
-func NewUserRepository(o orm.Ormer) model.UserRepository {
-	r := &userRepository{ormer: o}
-	r.userResource = NewResource(o, model.User{}, new(user))
-	return r
+func (r *userRepository) FindByUsername(username string) (*model.User, error) {
+	tu := user{}
+	err := r.ormer.QueryTable(user{}).Filter("user_name", username).One(&tu)
+	if err == orm.ErrNoRows {
+		return nil, model.ErrNotFound
+	}
+	if err != nil {
+		return nil, err
+	}
+	u := model.User(tu)
+	return &u, err
+}
+
+func (r *userRepository) UpdateLastLoginAt(id string) error {
+	now := time.Now()
+	tu := user{ID: id, LastLoginAt: &now}
+	_, err := r.ormer.Update(&tu, "last_login_at")
+	return err
 }
 
 var _ = model.User(user{})
diff --git a/server/app/app.go b/server/app/app.go
index ea25774c8..49320c08c 100644
--- a/server/app/app.go
+++ b/server/app/app.go
@@ -12,10 +12,15 @@ import (
 	"github.com/cloudsonic/sonic-server/server"
 	"github.com/deluan/rest"
 	"github.com/go-chi/chi"
+	"github.com/go-chi/jwtauth"
 	"github.com/google/uuid"
 )
 
-const initialUser = "admin"
+var initialUser = model.User{
+	UserName: "admin",
+	Name:     "Admin",
+	IsAdmin:  true,
+}
 
 type Router struct {
 	ds   model.DataStore
@@ -43,8 +48,12 @@ func (app *Router) routes() http.Handler {
 	// Basic unauthenticated ping
 	r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
 
+	r.Post("/login", Login(app.ds))
+
 	r.Route("/api", func(r chi.Router) {
 		// Add User resource
+		r.Use(jwtauth.Verifier(TokenAuth))
+		r.Use(Authenticator)
 		R(r, "/user", func(ctx context.Context) rest.Repository {
 			return app.ds.Resource(model.User{})
 		})
@@ -60,13 +69,10 @@ func (app *Router) createDefaultUser() {
 	if c == 0 {
 		id, _ := uuid.NewRandom()
 		initialPassword, _ := uuid.NewRandom()
-		log.Warn("Creating initial user. Please change the password!", "user", initialUser, "password", initialPassword)
-		app.ds.User().Put(&model.User{
-			ID:       id.String(),
-			Name:     initialUser,
-			Password: initialPassword.String(),
-			IsAdmin:  true,
-		})
+		log.Warn("Creating initial user. Please change the password!", "user", initialUser.UserName, "password", initialPassword)
+		initialUser.ID = id.String()
+		initialUser.Password = initialPassword.String()
+		app.ds.User().Put(&initialUser)
 	}
 }
 
diff --git a/server/app/auth.go b/server/app/auth.go
new file mode 100644
index 000000000..562d3e787
--- /dev/null
+++ b/server/app/auth.go
@@ -0,0 +1,144 @@
+package app
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/cloudsonic/sonic-server/model"
+	"github.com/deluan/rest"
+	"github.com/dgrijalva/jwt-go"
+	"github.com/go-chi/jwtauth"
+	log "github.com/sirupsen/logrus"
+)
+
+var (
+	tokenExpiration = 30 * time.Minute
+	issuer          = "CloudSonic"
+)
+
+var (
+	jwtSecret []byte
+	TokenAuth *jwtauth.JWTAuth
+)
+
+func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		data := make(map[string]string)
+		decoder := json.NewDecoder(r.Body)
+		if err := decoder.Decode(&data); err != nil {
+			log.Errorf("parsing request body: %#v", err)
+			rest.RespondWithError(w, http.StatusUnprocessableEntity, "Invalid request payload")
+			return
+		}
+		username := data["username"]
+		password := data["password"]
+
+		user, err := validateLogin(ds.User(), username, password)
+		if err != nil {
+			rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
+			return
+		}
+		if user == nil {
+			log.Warnf("Unsuccessful login: '%s', request: %v", username, r.Header)
+			rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
+			return
+		}
+
+		tokenString, err := createToken(user)
+		if err != nil {
+			rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
+		}
+		rest.RespondWithJSON(w, http.StatusOK,
+			map[string]interface{}{
+				"message":  "User '" + username + "' authenticated successfully",
+				"token":    tokenString,
+				"user":     strings.Title(user.UserName),
+				"username": username,
+			})
+	}
+}
+func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
+	u, err := userRepo.FindByUsername(userName)
+	if err == model.ErrNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	if u.Password != password {
+		return nil, nil
+	}
+	err = userRepo.UpdateLastLoginAt(u.ID)
+	if err != nil {
+		log.Error("Could not update LastLoginAt", "user", userName)
+	}
+	return u, nil
+}
+
+func createToken(u *model.User) (string, error) {
+	token := jwt.New(jwt.SigningMethodHS256)
+	claims := token.Claims.(jwt.MapClaims)
+	claims["iss"] = issuer
+	claims["sub"] = u.UserName
+
+	return touchToken(token)
+}
+
+func touchToken(token *jwt.Token) (string, error) {
+	expireIn := time.Now().Add(tokenExpiration).Unix()
+	claims := token.Claims.(jwt.MapClaims)
+	claims["exp"] = expireIn
+
+	return token.SignedString(jwtSecret)
+}
+
+func userFrom(claims jwt.MapClaims) *model.User {
+	user := &model.User{
+		UserName: claims["sub"].(string),
+	}
+	return user
+}
+
+func Authenticator(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		token, _, err := jwtauth.FromContext(r.Context())
+
+		if err != nil {
+			rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
+			return
+		}
+
+		if token == nil || !token.Valid {
+			rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
+			return
+		}
+
+		claims := token.Claims.(jwt.MapClaims)
+
+		newCtx := context.WithValue(r.Context(), "loggedUser", userFrom(claims))
+		newTokenString, err := touchToken(token)
+		if err != nil {
+			log.Errorf("signing new token: %v", err)
+			rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
+			return
+		}
+
+		w.Header().Set("Authorization", newTokenString)
+		next.ServeHTTP(w, r.WithContext(newCtx))
+	})
+}
+
+func init() {
+	// TODO Store jwtSecret in the DB
+	secret := os.Getenv("JWT_SECRET")
+	if secret == "" {
+		secret = "not so secret"
+		log.Warn("No JWT_SECRET env var found. Please set one.")
+	}
+	jwtSecret = []byte(secret)
+	TokenAuth = jwtauth.New("HS256", jwtSecret, nil)
+}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index de14617d0..c8d24ed64 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -8834,6 +8834,11 @@
         "object.assign": "^4.1.0"
       }
     },
+    "jwt-decode": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz",
+      "integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk="
+    },
     "killable": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 621cc64ad..bc89e38d6 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -6,6 +6,7 @@
     "@testing-library/jest-dom": "^5.0.0",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
+    "jwt-decode": "^2.2.0",
     "prop-types": "^15.7.2",
     "ra-data-json-server": "^3.1.2",
     "react": "^16.12.0",
diff --git a/ui/src/App.js b/ui/src/App.js
index a8c819b97..dd3fd5201 100644
--- a/ui/src/App.js
+++ b/ui/src/App.js
@@ -1,13 +1,17 @@
 // in src/App.js
 import React from 'react'
 import { Admin, Resource } from 'react-admin'
-import jsonServerProvider from 'ra-data-json-server'
+import dataProvider from './dataProvider'
+import authProvider from './authProvider'
 import { Login } from './layout'
 import user from './user'
 
-const dataProvider = jsonServerProvider('/app/api')
 const App = () => (
-  <Admin dataProvider={dataProvider} loginPage={Login}>
+  <Admin
+    dataProvider={dataProvider}
+    authProvider={authProvider}
+    loginPage={Login}
+  >
     <Resource name="user" {...user} />
   </Admin>
 )
diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js
new file mode 100644
index 000000000..0a9c84ee5
--- /dev/null
+++ b/ui/src/authProvider.js
@@ -0,0 +1,71 @@
+import jwtDecode from 'jwt-decode'
+
+const authProvider = {
+  login: ({ username, password }) => {
+    const request = new Request('/app/login', {
+      method: 'POST',
+      body: JSON.stringify({ username, password }),
+      headers: new Headers({ 'Content-Type': 'application/json' })
+    })
+    return fetch(request)
+      .then((response) => {
+        if (response.status < 200 || response.status >= 300) {
+          throw new Error(response.statusText)
+        }
+        return response.json()
+      })
+      .then((response) => {
+        // Validate token
+        jwtDecode(response.token)
+        localStorage.setItem('token', response.token)
+        localStorage.setItem('name', response.name)
+        localStorage.setItem('username', response.username)
+        return response
+      })
+      .catch((error) => {
+        if (
+          error.message === 'Failed to fetch' ||
+          error.stack === 'TypeError: Failed to fetch'
+        ) {
+          throw new Error('errors.network_error')
+        }
+
+        throw new Error(error)
+      })
+  },
+
+  logout: () => {
+    removeItems()
+    return Promise.resolve()
+  },
+
+  checkAuth: () => {
+    try {
+      const expireTime = jwtDecode(localStorage.getItem('token')).exp * 1000
+      const now = new Date().getTime()
+      return now < expireTime ? Promise.resolve() : Promise.reject()
+    } catch (e) {
+      return Promise.reject()
+    }
+  },
+
+  checkError: (error) => {
+    const { status } = error
+    // TODO Remove 403?
+    if (status === 401 || status === 403) {
+      removeItems()
+      return Promise.reject()
+    }
+    return Promise.resolve()
+  },
+
+  getPermissions: (params) => Promise.resolve()
+}
+
+const removeItems = () => {
+  localStorage.removeItem('token')
+  localStorage.removeItem('name')
+  localStorage.removeItem('username')
+}
+
+export default authProvider
diff --git a/ui/src/dataProvider.js b/ui/src/dataProvider.js
new file mode 100644
index 000000000..d50225ea4
--- /dev/null
+++ b/ui/src/dataProvider.js
@@ -0,0 +1,23 @@
+import { fetchUtils } from 'react-admin'
+import jsonServerProvider from 'ra-data-json-server'
+
+const httpClient = (url, options = {}) => {
+  if (!options.headers) {
+    options.headers = new Headers({ Accept: 'application/json' })
+  }
+  const token = localStorage.getItem('token')
+  if (token) {
+    options.headers.set('Authorization', `Bearer ${token}`)
+  }
+  return fetchUtils.fetchJson(url, options).then((response) => {
+    const token = response.headers.get('authorization')
+    if (token) {
+      localStorage.setItem('token', token)
+    }
+    return response
+  })
+}
+
+const dataProvider = jsonServerProvider('/app/api', httpClient)
+
+export default dataProvider
diff --git a/ui/src/user/UserCreate.js b/ui/src/user/UserCreate.js
index 7110457df..6a5dcab26 100644
--- a/ui/src/user/UserCreate.js
+++ b/ui/src/user/UserCreate.js
@@ -5,13 +5,16 @@ import {
   TextInput,
   PasswordInput,
   required,
+  email,
   SimpleForm
 } from 'react-admin'
 
 const UserCreate = (props) => (
   <Create {...props}>
     <SimpleForm redirect="list">
+      <TextInput source="userName" validate={[required()]} />
       <TextInput source="name" validate={[required()]} />
+      <TextInput source="email" validate={[required(), email()]} />
       <PasswordInput source="password" validate={[required()]} />
       <BooleanInput source="isAdmin" initialValue={false} />
     </SimpleForm>
diff --git a/ui/src/user/UserEdit.js b/ui/src/user/UserEdit.js
index 92a24842b..18345dd2c 100644
--- a/ui/src/user/UserEdit.js
+++ b/ui/src/user/UserEdit.js
@@ -6,6 +6,7 @@ import {
   PasswordInput,
   Edit,
   required,
+  email,
   SimpleForm
 } from 'react-admin'
 
@@ -15,7 +16,9 @@ const UserTitle = ({ record }) => {
 const UserEdit = (props) => (
   <Edit title={<UserTitle />} {...props}>
     <SimpleForm>
+      <TextInput source="userName" validate={[required()]} />
       <TextInput source="name" validate={[required()]} />
+      <TextInput source="email" validate={[required(), email()]} />
       <PasswordInput source="password" validate={[required()]} />
       <BooleanInput source="isAdmin" initialValue={false} />
       <DateField source="lastLoginAt" />
diff --git a/ui/src/user/UserList.js b/ui/src/user/UserList.js
index 7c1c3cae2..3f740523c 100644
--- a/ui/src/user/UserList.js
+++ b/ui/src/user/UserList.js
@@ -23,7 +23,7 @@ const UserList = (props) => {
   return (
     <List
       {...props}
-      sort={{ field: 'name', order: 'ASC' }}
+      sort={{ field: 'userName', order: 'ASC' }}
       exporter={false}
       filters={<UserFilter />}
     >
@@ -34,9 +34,8 @@ const UserList = (props) => {
         />
       ) : (
         <Datagrid rowClick="edit">
-          <TextField source="name" />
+          <TextField source="userName" />
           <BooleanField source="isAdmin" />
-          <DateField source="lastLoginAt" locales="pt-BR" />
           <DateField source="lastAccessAt" locales="pt-BR" />
           <DateField source="updatedAt" locales="pt-BR" />
         </Datagrid>