diff --git a/.env.example b/.env.example
index f7a0c16..bb5a696 100644
--- a/.env.example
+++ b/.env.example
@@ -9,7 +9,23 @@ BACKEND_ADDRESS=127.0.0.1
 BACKEND_PORT=3000
 
 # Application database URL (required)
+# Postgres:
 DB_URL=postgres://user:password@host:port/dbname
+# SQLite:
+# DB_URL=sqlite:///path/to/app.db
+
+# The server key is used to sign JWT tokens (required)
+# It should be a random string with at least 32 characters.
+# You can generate a random string with the following command:
+# openssl rand -base64 32
+SERVER_KEY=your_server_key
+
+# Admin password (required)
+# The admin password is used to authenticate the admin user.
+ADMIN_PASSWORD=your_admin_password
+
+# Admin user email (required)
+ADMIN_EMAIL=admin@example.com
 
 # If the location data is stored on another db than the one used by the backend,
 # you can specify a different database URL here.
@@ -21,8 +37,12 @@ DB_URL=postgres://user:password@host:port/dbname
 # Location database dialect (default: inferred from the URL)
 # DB_LOCATION_DIALECT=postgres
 
+# Prefix for the application tables (default: empty).
+# Note that this does not apply to DB_LOCATION_TABLE if it is set.
+DB_TABLE_PREFIX=
+
 # Name of the table that contains the location points (required)
-DB_LOCATION_TABLE=gpsdata
+DB_LOCATION_TABLE=location_history
 
 ## Database mappings
 # The name of the column that contains the primary key of each location point
diff --git a/.gitignore b/.gitignore
index b1f722a..7410ef1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,8 @@ coverage
 *.sw?
 *.vim
 *.tsbuildinfo
+
+# Database files
+*.db
+*.sqlite
+*.sqlite3
diff --git a/package-lock.json b/package-lock.json
index c593992..e8e29ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,19 +7,23 @@
     "": {
       "name": "server",
       "version": "1.0.0",
-      "license": "ISC",
+      "license": "GPL-3.0",
       "dependencies": {
+        "bcryptjs": "^3.0.2",
+        "body-parser": "^1.20.3",
         "cors": "^2.8.5",
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
-        "passport": "^0.7.0",
+        "jsonwebtoken": "^9.0.2",
         "pg": "^8.13.3",
         "sequelize": "^6.37.5",
-        "sqlite3": "^5.1.7"
+        "sqlite3": "^5.1.7",
+        "uuid": "^11.1.0"
       },
       "devDependencies": {
         "@types/cors": "^2.8.17",
         "@types/express": "^5.0.0",
+        "@types/jsonwebtoken": "^9.0.9",
         "@types/node": "^22.13.4",
         "nodemon": "^3.1.9",
         "typescript": "^5.7.3"
@@ -141,6 +145,17 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/jsonwebtoken": {
+      "version": "9.0.9",
+      "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
+      "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/ms": "*",
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/mime": {
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -370,6 +385,15 @@
       ],
       "license": "MIT"
     },
+    "node_modules/bcryptjs": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
+      "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
+      "license": "BSD-3-Clause",
+      "bin": {
+        "bcrypt": "bin/bcrypt"
+      }
+    },
     "node_modules/binary-extensions": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -475,6 +499,12 @@
         "ieee754": "^1.1.13"
       }
     },
+    "node_modules/buffer-equal-constant-time": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -760,6 +790,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1505,12 +1544,103 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/jsonwebtoken": {
+      "version": "9.0.2",
+      "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+      "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "jws": "^3.2.2",
+        "lodash.includes": "^4.3.0",
+        "lodash.isboolean": "^3.0.3",
+        "lodash.isinteger": "^4.0.4",
+        "lodash.isnumber": "^3.0.3",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.isstring": "^4.0.1",
+        "lodash.once": "^4.0.0",
+        "ms": "^2.1.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/jsonwebtoken/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/jwa": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
+      "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-equal-constant-time": "1.0.1",
+        "ecdsa-sig-formatter": "1.0.11",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/jws": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+      "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+      "license": "MIT",
+      "dependencies": {
+        "jwa": "^1.4.1",
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "license": "MIT"
     },
+    "node_modules/lodash.includes": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isboolean": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isinteger": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isnumber": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isstring": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+      "license": "MIT"
+    },
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -2017,32 +2147,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/passport": {
-      "version": "0.7.0",
-      "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
-      "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
-      "license": "MIT",
-      "dependencies": {
-        "passport-strategy": "1.x.x",
-        "pause": "0.0.1",
-        "utils-merge": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.4.0"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/jaredhanson"
-      }
-    },
-    "node_modules/passport-strategy": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
-      "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
-      "engines": {
-        "node": ">= 0.4.0"
-      }
-    },
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -2059,11 +2163,6 @@
       "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
       "license": "MIT"
     },
-    "node_modules/pause": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
-      "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
-    },
     "node_modules/pg": {
       "version": "8.13.3",
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz",
@@ -2567,6 +2666,15 @@
       "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
       "license": "MIT"
     },
+    "node_modules/sequelize/node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
     "node_modules/serve-static": {
       "version": "1.16.2",
       "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
@@ -3114,12 +3222,16 @@
       }
     },
     "node_modules/uuid": {
-      "version": "8.3.2",
-      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
-      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
       "license": "MIT",
       "bin": {
-        "uuid": "dist/bin/uuid"
+        "uuid": "dist/esm/bin/uuid"
       }
     },
     "node_modules/validator": {
diff --git a/package.json b/package.json
index 9793985..de19dc0 100644
--- a/package.json
+++ b/package.json
@@ -21,20 +21,24 @@
   },
   "keywords": [],
   "author": "",
-  "license": "ISC",
+  "license": "GPL-3.0",
   "type": "commonjs",
   "dependencies": {
+    "bcryptjs": "^3.0.2",
+    "body-parser": "^1.20.3",
     "cors": "^2.8.5",
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
-    "passport": "^0.7.0",
+    "jsonwebtoken": "^9.0.2",
     "pg": "^8.13.3",
     "sequelize": "^6.37.5",
-    "sqlite3": "^5.1.7"
+    "sqlite3": "^5.1.7",
+    "uuid": "^11.1.0"
   },
   "devDependencies": {
     "@types/cors": "^2.8.17",
     "@types/express": "^5.0.0",
+    "@types/jsonwebtoken": "^9.0.9",
     "@types/node": "^22.13.4",
     "nodemon": "^3.1.9",
     "typescript": "^5.7.3"
diff --git a/src/App.ts b/src/App.ts
new file mode 100644
index 0000000..0c7ecd6
--- /dev/null
+++ b/src/App.ts
@@ -0,0 +1,56 @@
+import cors from 'cors';
+import express from 'express';
+import bodyParser from 'body-parser';
+
+import { useGlobals } from './globals';
+import Routes from './routes';
+
+class App {
+  private readonly app: express.Express;
+  private readonly address: string;
+  private readonly port: number;
+
+  private constructor({
+    app,
+    address,
+    port,
+    routes,
+  }: any) {
+    useGlobals();
+    $db.sync().then(() => {
+      $repos.userRoles.init();
+      $repos.users.syncAdminUser();
+    })
+
+    this.app = app;
+    this.address = address;
+    this.port = port;
+
+    app.use(cors());
+    app.use(bodyParser.json());
+    app.use(express.static('frontend/dist'));
+    routes.register(app)
+  }
+
+  public static fromEnv(): App {
+    const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
+    const port = new Number(process.env.BACKEND_PORT || 3000).valueOf();
+    const app = express();
+    const routes = new Routes();
+
+    return new App({
+      app,
+      address,
+      port,
+      routes,
+    });
+  }
+
+  public listen(): void {
+    this.app.listen(this.port, this.address, () => {
+      console.log(`Server is running on port ${this.address}:${this.port}`);
+    });
+  }
+}
+
+export default App;
diff --git a/src/Secrets.ts b/src/Secrets.ts
new file mode 100644
index 0000000..384c2a3
--- /dev/null
+++ b/src/Secrets.ts
@@ -0,0 +1,40 @@
+class Secrets {
+  public readonly serverKey: string;
+  public readonly adminPassword: string;
+  public readonly adminEmail: string;
+
+  private constructor({
+    serverKey,
+    adminPassword,
+    adminEmail,
+  }: any) {
+    this.serverKey = serverKey;
+    this.adminPassword = adminPassword;
+    this.adminEmail = adminEmail;
+  }
+
+  public static fromEnv(): Secrets {
+    if (!process.env.SERVER_KEY?.length) {
+      throw new Error(
+        'SERVER_KEY not found in environment.\n' +
+        'Generate one with `openssl rand -base64 32` and add it to your environment.'
+      );
+    }
+
+    if (!process.env.ADMIN_PASSWORD?.length) {
+      throw new Error('ADMIN_PASSWORD not found in environment.');
+    }
+
+    if (!process.env.ADMIN_EMAIL?.length) {
+      throw new Error('ADMIN_EMAIL not found in environment.');
+    }
+
+    return new Secrets({
+      serverKey: process.env.SERVER_KEY,
+      adminPassword: process.env.ADMIN_PASSWORD,
+      adminEmail: process.env.ADMIN_EMAIL,
+    });
+  }
+}
+
+export default Secrets;
diff --git a/src/auth.ts b/src/auth.ts
new file mode 100644
index 0000000..0f820c1
--- /dev/null
+++ b/src/auth.ts
@@ -0,0 +1,74 @@
+import { Request } from 'express';
+
+import { Forbidden, Unauthorized } from './errors';
+import { Optional } from '~/types';
+import { RoleName, User, UserSession } from '~/models';
+import Route from './routes/Route';
+
+class AuthInfo {
+  public user: User;
+  public session: Optional<UserSession>;
+
+  constructor(user: User, session: Optional<UserSession> = null) {
+    this.user = user;
+    this.session = session;
+  }
+}
+
+function authenticate(roles: RoleName[] = []) {
+  return function (route: any, method: string) {
+    const routeClass = (<typeof Route> route.constructor);
+    routeClass.preRequestHandlers[method] = async (req: Request): Promise<AuthInfo> => {
+      let user: Optional<User>;
+      let session: Optional<UserSession>;
+
+      // Check the `session` cookie or the `Authorization` header for the session token
+      let token = req.cookies?.session;
+      if (!token?.length) {
+        const authHeader = req.headers?.authorization;
+        if (authHeader?.startsWith('Bearer ')) {
+          token = authHeader.slice(7);
+        }
+      }
+
+      // Check if the token is valid
+      if (token?.length) {
+        session = await $repos.userSessions.byToken(token);
+        if (session) {
+          user = await $repos.users.get(session.userId);
+        }
+      }
+
+      if (!(session && user)) {
+        // Check the `username` and `password` query or body parameters
+        const username = req.body?.username || req.query?.username;
+        const password = req.body?.password || req.query?.password;
+
+        if (username?.length && password?.length) {
+          user = await $repos.users.find(username);
+          if (!(user && user.checkPassword(password))) {
+            user = null;
+          }
+        }
+      }
+
+      if (!user) {
+        throw new Unauthorized('Invalid credentials');
+      }
+
+      if (roles.length) {
+        // Check if the user has the required roles
+        const userRoles = new Set((await user.roles()).map((role) => role.name));
+        const missingPermissions = roles.filter((role) => !userRoles.has(role));
+
+        if (missingPermissions.length) {
+          throw new Forbidden('Missing required roles: [' + missingPermissions.join(', ') + ']');
+        }
+      }
+
+      return new AuthInfo(user, session);
+    }
+  }
+}
+
+export { AuthInfo, authenticate };
diff --git a/src/db/Db.ts b/src/db/Db.ts
index 103b370..ada801e 100644
--- a/src/db/Db.ts
+++ b/src/db/Db.ts
@@ -1,6 +1,10 @@
 import { Sequelize, Dialect } from 'sequelize';
 
 import GPSData from './types/GPSData';
+import Role from './types/Role';
+import User from './types/User';
+import UserRole from './types/UserRole';
+import UserSession from './types/UserSession';
 
 class Db {
   private readonly url: string;
@@ -9,6 +13,7 @@ class Db {
   public readonly locationTableColumns: Record<string, string>;
   private readonly dialect: Dialect;
   private readonly locationDialect: Dialect;
+  private readonly tablePrefix: string;
   private readonly appDb: Sequelize;
   private readonly locationDb: Sequelize;
 
@@ -18,18 +23,20 @@ class Db {
     opts: {
       url: string,
       locationUrl: string,
-      locationTable: string,
+      locationTable?: string | null,
       locationTableColumns: Record<string, string>,
       dialect: Dialect,
       locationDialect: Dialect | null,
+      tablePrefix?: string | null,
     }
   ) {
     this.url = opts.url;
     this.locationUrl = opts.locationUrl;
-    this.locationTable = opts.locationTable;
     this.locationTableColumns = opts.locationTableColumns
     this.dialect = opts.dialect as Dialect;
     this.locationDialect = (opts.locationDialect || this.dialect) as Dialect;
+    this.tablePrefix = opts.tablePrefix || '';
+    this.locationTable = opts.locationTable || this.tableName('location_history');
 
     this.appDb = new Sequelize(this.url, {
       dialect: this.dialect,
@@ -53,6 +60,7 @@ class Db {
     opts.locationTable = process.env.DB_LOCATION_TABLE;
     opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
     opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0];
+    opts.tablePrefix = process.env.DB_TABLE_PREFIX;
 
     if (!opts.url?.length) {
       console.error('No DB_URL provided');
@@ -81,7 +89,7 @@ class Db {
       'country',
       'postal_code'
     ].reduce((acc: any, name: string) => {
-      acc[name] = process.env[this.prefixed(name)];
+      acc[name] = process.env[this.prefixedEnv(name)];
       if (!acc[name]?.length && requiredColumns[name]) {
         // Default to the name of the required field
         acc[name] = name;
@@ -93,10 +101,66 @@ class Db {
     return new Db(opts);
   }
 
-  private static prefixed(name: string): string {
+  private static prefixedEnv(name: string): string {
     return `${Db.envColumnPrefix}${name.toUpperCase()}`;
   }
 
+  public tableName(table: string): string {
+    return `${this.tablePrefix}${table}`;
+  }
+
+  public async sync() {
+    console.log('Syncing databases');
+
+    const gpsData = this.GPSData();
+    const role = this.Role();
+    const user = this.User();
+    const userRole = this.UserRole();
+    const userSession = this.UserSession();
+    this.initConstraints();
+
+    await gpsData.sync();
+    await role.sync();
+    await user.sync();
+    await userRole.sync();
+    await userSession.sync();
+
+    await this.appDb.sync();
+    console.log('Database sync completed');
+  }
+
+  private initConstraints() {
+    this.appDb.models.UserSession.belongsTo(this.appDb.models.User, {
+      foreignKey: 'userId',
+      targetKey: 'id',
+      as: 'user',
+      onDelete: 'CASCADE'
+    });
+
+    this.appDb.models.User.hasMany(this.appDb.models.UserSession, {
+      foreignKey: 'userId',
+      sourceKey: 'id',
+      as: 'sessions'
+    });
+
+    this.appDb.models.User.belongsToMany(this.appDb.models.Role, {
+      through: this.appDb.models.UserRole,
+      foreignKey: 'userId',
+      otherKey: 'roleId',
+      as: 'roles'
+    });
+
+    this.appDb.models.Role.belongsToMany(this.appDb.models.User, {
+      through: this.appDb.models.UserRole,
+      foreignKey: 'roleId',
+      otherKey: 'userId',
+      as: 'users'
+    });
+
+    this.appDb.models.UserSession.sync();
+    this.appDb.sync();
+  }
+
   /**
    * Tables
    */
@@ -104,9 +168,50 @@ class Db {
   public GPSData() {
     return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), {
       tableName: this.locationTable,
-      timestamps: false
+      timestamps: false,
     });
   }
+
+  public Role() {
+    return this.appDb.define('Role', Role(), {
+      tableName: this.tableName('roles'),
+      timestamps: false,
+    });
+  }
+
+  public User() {
+    return this.appDb.define('User', User(), {
+      indexes: [
+        {
+          unique: true,
+          fields: ['username', 'email'],
+        },
+      ],
+      tableName: this.tableName('users'),
+      timestamps: false,
+    });
+  }
+
+  public UserRole() {
+    return this.appDb.define('UserRole', UserRole(), {
+      tableName: this.tableName('user_roles'),
+      timestamps: false,
+    });
+  }
+
+  public UserSession() {
+    const ret = this.appDb.define('UserSession', UserSession(), {
+      indexes: [
+        {
+          fields: ['userId'],
+        },
+      ],
+      tableName: this.tableName('user_sessions'),
+      timestamps: false,
+    });
+
+    return ret;
+  }
 }
 
 export default Db;
diff --git a/src/db/types/Role.ts b/src/db/types/Role.ts
new file mode 100644
index 0000000..5a1aa41
--- /dev/null
+++ b/src/db/types/Role.ts
@@ -0,0 +1,24 @@
+import { DataTypes, } from 'sequelize';
+
+function Role(): Record<string, any> {
+  return {
+    id: {
+      type: DataTypes.INTEGER,
+      primaryKey: true,
+      allowNull: false,
+      autoIncrement: true,
+    },
+    name: {
+      type: DataTypes.STRING,
+      allowNull: false,
+      unique: true
+    },
+    createdAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+    },
+  };
+}
+
+export default Role;
diff --git a/src/db/types/User.ts b/src/db/types/User.ts
new file mode 100644
index 0000000..4650164
--- /dev/null
+++ b/src/db/types/User.ts
@@ -0,0 +1,46 @@
+import { DataTypes, } from 'sequelize';
+
+function User(): Record<string, any> {
+  return {
+    id: {
+      type: DataTypes.INTEGER,
+      primaryKey: true,
+      autoIncrement: true
+    },
+    username: {
+      type: DataTypes.STRING,
+      allowNull: false,
+      unique: true
+    },
+    email: {
+      type: DataTypes.STRING,
+      allowNull: false,
+      unique: true
+    },
+    password: {
+      type: DataTypes.STRING,
+      allowNull: false
+    },
+    firstName: {
+      type: DataTypes.STRING,
+      allowNull: false
+    },
+    lastName: {
+      type: DataTypes.STRING,
+      allowNull: false
+    },
+    createdAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+    },
+    updatedAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+      onUpdate: () => new Date(),
+    },
+  };
+}
+
+export default User;
diff --git a/src/db/types/UserRole.ts b/src/db/types/UserRole.ts
new file mode 100644
index 0000000..246f22d
--- /dev/null
+++ b/src/db/types/UserRole.ts
@@ -0,0 +1,32 @@
+import { DataTypes, } from 'sequelize';
+
+function UserRole(): Record<string, any> {
+  return {
+    id: {
+      type: DataTypes.INTEGER,
+      primaryKey: true,
+      autoIncrement: true
+    },
+    userId: {
+      type: DataTypes.INTEGER,
+      allowNull: false,
+    },
+    roleId: {
+      type: DataTypes.INTEGER,
+      allowNull: false,
+    },
+    createdAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+    },
+    updatedAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+      onUpdate: () => new Date(),
+    },
+  };
+}
+
+export default UserRole;
diff --git a/src/db/types/UserSession.ts b/src/db/types/UserSession.ts
new file mode 100644
index 0000000..2e9ec0d
--- /dev/null
+++ b/src/db/types/UserSession.ts
@@ -0,0 +1,34 @@
+import { DataTypes } from 'sequelize';
+import { v4 as uuidv4 } from 'uuid';
+
+function UserSession(): Record<string, any> {
+  return {
+    id: {
+      type: DataTypes.UUID,
+      primaryKey: true,
+      allowNull: false,
+      defaultValue: () => uuidv4(),
+    },
+    userId: {
+      type: DataTypes.INTEGER,
+      allowNull: false,
+    },
+    expiresAt: {
+      type: DataTypes.DATE,
+      allowNull: true,
+    },
+    createdAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+    },
+    updatedAt: {
+      type: DataTypes.DATE,
+      allowNull: false,
+      defaultValue: () => new Date(),
+      onUpdate: () => new Date(),
+    },
+  }
+}
+
+export default UserSession;
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644
index 0000000..80340c9
--- /dev/null
+++ b/src/errors.ts
@@ -0,0 +1,20 @@
+abstract class ApplicationError extends Error {
+  constructor(public message: string) {
+    super(message);
+  }
+}
+
+class BadRequest extends ApplicationError { }
+class ValidationError extends BadRequest { }
+class Unauthorized extends BadRequest { }
+class Forbidden extends BadRequest { }
+class NotFound extends BadRequest { }
+
+export {
+  ApplicationError,
+  BadRequest,
+  Forbidden,
+  NotFound,
+  Unauthorized,
+  ValidationError,
+};
diff --git a/src/globals.ts b/src/globals.ts
index 33017da..2ddb3eb 100644
--- a/src/globals.ts
+++ b/src/globals.ts
@@ -1,6 +1,7 @@
 import dotenv from 'dotenv';
 
 import { Db } from './db';
+import Secrets from './Secrets';
 import Repositories from './repos';
 
 dotenv.config();
@@ -8,9 +9,11 @@ dotenv.config();
 declare global {
   var $db: Db;
   var $repos: Repositories;
+  var $secrets: Secrets;
 }
 
 export function useGlobals() {
+  globalThis.$secrets = Secrets.fromEnv();
   globalThis.$db = Db.fromEnv();
-  globalThis.$repos = new Repositories(globalThis.$db);
+  globalThis.$repos = new Repositories();
 }
diff --git a/src/helpers/random.ts b/src/helpers/random.ts
new file mode 100644
index 0000000..a69f554
--- /dev/null
+++ b/src/helpers/random.ts
@@ -0,0 +1,13 @@
+function randomString(length: number) {
+  let result = '';
+  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  const charactersLength = characters.length;
+  let counter = 0;
+  while (counter < length) {
+    result += characters.charAt(Math.floor(Math.random() * charactersLength));
+    counter += 1;
+  }
+  return result;
+}
+
+export { randomString };
diff --git a/src/helpers/security.ts b/src/helpers/security.ts
new file mode 100644
index 0000000..7fa7377
--- /dev/null
+++ b/src/helpers/security.ts
@@ -0,0 +1,11 @@
+function maskPassword(obj: Record<string, any>): Record<string, any> {
+  if (obj.password) {
+    delete obj.password;
+  }
+
+  return obj;
+}
+
+export {
+  maskPassword,
+};
diff --git a/src/main.ts b/src/main.ts
index 5839b0b..4847d9b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,20 +1,4 @@
-import cors from 'cors';
-import express from 'express';
+import App from './App';
 
-import { useGlobals } from './globals';
-useGlobals();
-
-import Routes from './routes';
-
-const app = express();
-const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
-const port = process.env.BACKEND_PORT || 3000;
-const routes = new Routes();
-
-app.use(cors());
-app.use(express.static('frontend/dist'));
-routes.register(app)
-
-app.listen(new Number(port).valueOf(), address, () => {
-  console.log(`Server is running on port ${address}:${port}`);
-});
+const app = App.fromEnv();
+app.listen();
diff --git a/src/models/Role.ts b/src/models/Role.ts
new file mode 100644
index 0000000..268d08b
--- /dev/null
+++ b/src/models/Role.ts
@@ -0,0 +1,57 @@
+import { Op } from 'sequelize';
+import { Optional } from '~/types';
+import RoleName from './RoleName';
+
+class Role {
+  public id: number;
+  public name: RoleName;
+  public createdAt: Optional<Date>;
+
+  constructor({
+    id,
+    name,
+    createdAt = null,
+  }: any) {
+    this.id = id;
+    this.name = name;
+    this.createdAt = createdAt;
+  }
+
+  public async get(role: number | string): Promise<Optional<Role>> {
+    const dbRole = await $db.Role().findOne({
+      where: {
+        [Op.or]: [
+          { id: role },
+          { name: role },
+        ],
+      },
+    });
+
+    if (!dbRole) {
+      return null;
+    }
+
+    return new Role(dbRole.dataValues);
+  }
+
+  public async save(): Promise<void> {
+    const userRole = await $db.Role().findOne({
+      where: {
+        name: this.name,
+      },
+    });
+
+    if (!userRole) {
+      await $db.Role().create({
+        name: this.name,
+        createdAt: new Date(),
+      });
+    } else {
+      await userRole.update({
+        name: this.name,
+      });
+    }
+  }
+}
+
+export default Role;
diff --git a/src/models/RoleName.ts b/src/models/RoleName.ts
new file mode 100644
index 0000000..e038159
--- /dev/null
+++ b/src/models/RoleName.ts
@@ -0,0 +1,6 @@
+enum RoleName {
+  Admin = 'admin',
+  User = 'user',
+}
+
+export default RoleName;
diff --git a/src/models/User.ts b/src/models/User.ts
new file mode 100644
index 0000000..d9b5d06
--- /dev/null
+++ b/src/models/User.ts
@@ -0,0 +1,113 @@
+import bcrypt from 'bcryptjs';
+import { Op } from 'sequelize';
+
+import { Optional } from '~/types';
+import Role from './Role';
+import RoleName from './RoleName';
+
+class User {
+  public id: number;
+  public username: string;
+  public password: string;
+  public email: string;
+  public firstName: Optional<string>;
+  public lastName: Optional<string>;
+  public createdAt: Optional<Date>;
+  public updatedAt: Optional<Date>;
+
+  constructor({
+    id,
+    username,
+    email,
+    password,
+    firstName = null,
+    lastName = null,
+    createdAt = null,
+    updatedAt = null,
+  }: User) {
+    this.id = id;
+    this.username = username;
+    this.email = email;
+    this.password = password;
+    this.firstName = firstName;
+    this.lastName = lastName;
+    this.createdAt = createdAt;
+    this.updatedAt = updatedAt;
+  }
+
+  public static hashPassword(password: string): string {
+    return bcrypt.hashSync(password, bcrypt.genSaltSync(10));
+  }
+
+  public checkPassword(password: string): boolean {
+    return bcrypt.compareSync(password, this.password);
+  }
+
+  public async roles(): Promise<Role[]> {
+    return (
+      await $db.UserRole().findAll({
+        where: {
+          userId: this.id,
+        },
+      })
+    ).map((role) => new Role(role.dataValues));
+  }
+
+  public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> {
+    const rolesToFetch = roles.map((role) => {
+      if (role instanceof Role) {
+        return role.id || role.name;
+      }
+
+      return role;
+    });
+
+    const roleIds = new Set(
+      (
+        await $db.Role().findAll({
+          where: {
+              [Op.or]: {
+              id: rolesToFetch,
+              name: rolesToFetch,
+            },
+          },
+        })
+      ).map((role) => (role as any).id)
+    );
+
+    const userRoles = await $db.UserRole().findAll({
+      where: {
+        userId: this.id,
+      },
+    });
+
+    const userRoleIds = new Set(userRoles.map((role) => (role as any).roleId));
+    const toAdd = [...roleIds].filter((roleId) => !userRoleIds.has(roleId));
+    const toRemove = userRoles.filter((role) => !roleIds.has((role as any).roleId)).map((role) => (role as any).roleId);
+
+    await $db.UserRole().bulkCreate(
+      toAdd.map((roleId) => ({
+        userId: this.id,
+        roleId: roleId,
+      })),
+    );
+
+    await $db.UserRole().destroy({
+      where: {
+        userId: this.id,
+        roleId: toRemove,
+      },
+    });
+  }
+
+  public async save(): Promise<void> {
+    this.password = User.hashPassword(this.password);
+    await $db.User().update(this, {
+      where: {
+        id: this.id,
+      },
+    });
+  }
+}
+
+export default User;
diff --git a/src/models/UserSession.ts b/src/models/UserSession.ts
new file mode 100644
index 0000000..912e12d
--- /dev/null
+++ b/src/models/UserSession.ts
@@ -0,0 +1,48 @@
+import jwt from 'jsonwebtoken';
+
+import { Optional } from '~/types';
+
+class UserSession {
+  public id: string;
+  public userId: number;
+  public expiresAt: Optional<Date>;
+  public createdAt: Optional<Date>;
+  public updatedAt: Optional<Date>;
+
+  constructor({
+    id,
+    userId,
+    expiresAt = null,
+    createdAt = null,
+    updatedAt = null,
+  }: any) {
+    this.id = id;
+    this.userId = userId;
+    this.expiresAt = expiresAt;
+    this.createdAt = createdAt;
+    this.updatedAt = updatedAt;
+
+    ['expiresAt', 'createdAt', 'updatedAt'].forEach((key) => {
+      if ((this as any)[key] && !((this as any)[key] instanceof Date)) {
+        (this as any)[key] = new Date((this as any)[key]);
+      }
+    });
+  }
+
+  public getToken() {
+    return jwt.sign({
+      sessionId: this.id,
+      userId: this.userId,
+    }, $secrets.serverKey);
+  }
+
+  public destroy() {
+    return $db.UserSession().destroy({
+      where: {
+        id: this.id,
+      },
+    });
+  }
+}
+
+export default UserSession;
diff --git a/src/models/index.ts b/src/models/index.ts
index 19bc5f1..363374a 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -1,5 +1,13 @@
 import GPSPoint from "./GPSPoint";
+import Role from "./Role";
+import RoleName from "./RoleName";
+import User from "./User";
+import UserSession from "./UserSession";
 
 export {
   GPSPoint,
+  Role,
+  RoleName,
+  User,
+  UserSession,
 };
diff --git a/src/repos/LocationRepository.ts b/src/repos/Location.ts
similarity index 75%
rename from src/repos/LocationRepository.ts
rename to src/repos/Location.ts
index a07a951..9decd44 100644
--- a/src/repos/LocationRepository.ts
+++ b/src/repos/Location.ts
@@ -1,19 +1,12 @@
-import { Db } from '~/db';
 import { GPSPoint } from '../models';
 import { LocationRequest } from '../requests';
 
-class LocationRepository {
-  private db: Db;
-
-  constructor(db: Db) {
-    this.db = db;
-  }
-
+class Location {
   public async getHistory(query: LocationRequest): Promise<GPSPoint[]> {
     let apiResponse: any[] = [];
 
     try {
-      apiResponse = await this.db.GPSData().findAll(query.toMap(this.db));
+      apiResponse = await $db.GPSData().findAll(query.toMap($db));
     } catch (error) {
       throw new Error(`Error fetching data: ${error}`);
     }
@@ -21,7 +14,7 @@ class LocationRepository {
     try {
       return apiResponse.map((p) => {
         const data = p.dataValues;
-        const mappings: any = this.db.locationTableColumns;
+        const mappings: any = $db.locationTableColumns;
 
         return new GPSPoint({
           id: data[mappings.id],
@@ -41,4 +34,4 @@ class LocationRepository {
   }
 }
 
-export default LocationRepository;
+export default Location;
diff --git a/src/repos/UserRoles.ts b/src/repos/UserRoles.ts
new file mode 100644
index 0000000..03176c6
--- /dev/null
+++ b/src/repos/UserRoles.ts
@@ -0,0 +1,13 @@
+import { Role, RoleName } from '../models';
+
+class UserRoles {
+  private static userRoleNames: string[] = Object.values(RoleName);
+
+  public async init(): Promise<void> {
+    UserRoles.userRoleNames.forEach(async (name) => {
+      new Role({ name }).save();
+    });
+  }
+}
+
+export default UserRoles;
diff --git a/src/repos/UserSessions.ts b/src/repos/UserSessions.ts
new file mode 100644
index 0000000..42c11d4
--- /dev/null
+++ b/src/repos/UserSessions.ts
@@ -0,0 +1,58 @@
+import jwt from 'jsonwebtoken';
+
+import { Optional } from '~/types';
+import { Unauthorized } from '../errors';
+import { UserSession } from '../models';
+
+class UserSessions {
+  public async find(sessionId: string): Promise<Optional<UserSession>> {
+    const dbSession = await $db.UserSession().findByPk(sessionId)
+    if (!dbSession) {
+      return null;
+    }
+
+    const session = new UserSession(dbSession.dataValues);
+    if (session.expiresAt && session.expiresAt < new Date()) {
+      await session.destroy();
+      return null;
+    }
+
+    return session;
+  }
+
+  public async create(userId: number, expiresAt: Optional<Date> = null): Promise<UserSession> {
+    const session = await $db.UserSession().create({
+      userId,
+      expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
+    });
+
+    return new UserSession(session.dataValues);
+  }
+
+  public async byToken(token: string): Promise<Optional<UserSession>> {
+    let payload: Record<string, any>
+
+    try {
+      payload = jwt.verify(token, $secrets.serverKey) as {
+        sessionId: string, userId: number
+      };
+    } catch (error) {
+      throw new Unauthorized('Invalid token');
+    }
+
+    const session = await this.find(payload.sessionId);
+    let expiresAt = session?.expiresAt;
+
+    if (
+      !session ||
+      session.userId !== payload.userId ||
+      (expiresAt && expiresAt < new Date())
+    ) {
+      throw new Unauthorized('Invalid token');
+    }
+
+    return session;
+  }
+}
+
+export default UserSessions;
diff --git a/src/repos/Users.ts b/src/repos/Users.ts
new file mode 100644
index 0000000..7053cbf
--- /dev/null
+++ b/src/repos/Users.ts
@@ -0,0 +1,139 @@
+import { Op } from 'sequelize';
+
+import { Optional } from '~/types';
+import { RoleName } from '../models';
+import { User } from '../models';
+import { ValidationError } from '../errors';
+
+class Users {
+  public async syncAdminUser(): Promise<User> {
+    const adminPassword = $secrets.adminPassword;
+    let adminUser = await this.find('admin');
+    const adminEmail = $secrets.adminEmail;
+
+    if (adminUser) {
+      let changed = false;
+
+      if (!adminUser.checkPassword(adminPassword)) {
+        adminUser.password = adminPassword;
+        changed = true;
+      }
+
+      if (adminUser.email !== adminEmail) {
+        adminUser.email = adminEmail;
+        changed = true;
+      }
+
+      if (!changed) {
+        return adminUser;
+      }
+
+      await adminUser.save();
+    } else {
+      console.log('Creating admin user');
+      adminUser = await this.create({
+        username: 'admin',
+        email: adminEmail,
+        password: adminPassword,
+        firstName: 'Admin',
+        lastName: 'User',
+      });
+
+      console.log('Admin user created');
+    }
+
+    await adminUser.setRoles([RoleName.Admin]);
+    return adminUser;
+  }
+
+  public async get(userId: number): Promise<Optional<User>> {
+    const dbUser = await $db.User().findByPk(userId);
+
+    if (!dbUser) {
+      return null;
+    }
+
+    return new User(dbUser.dataValues);
+  }
+
+  public async find(username: string): Promise<Optional<User>> {
+    const dbUser = await $db.User().findOne({
+      where: {
+        [Op.or]: {
+          username,
+          email: username,
+        },
+      },
+    });
+
+    if (!dbUser) {
+      return null;
+    }
+
+    return new User(dbUser.dataValues);
+  }
+
+  public async set(username: string, {
+    password = null,
+    email = null,
+    firstName = null,
+    lastName = null,
+  }: any): Promise<void> {
+    const dbUser = await this.find(username);
+    let changed = false;
+
+    if (!dbUser) {
+      console.log(`User ${username} not found`);
+      return;
+    }
+
+    if (password?.length) {
+      console.log(`Updating password for ${username}`);
+      dbUser.password = password;
+      changed = true;
+    }
+
+    if (email?.length) {
+      if (!email.includes('@')) {
+        throw new ValidationError('Invalid email');
+      }
+
+      dbUser.email = email;
+      changed = true;
+    }
+
+    if (firstName?.length) {
+      dbUser.firstName = firstName;
+      changed = true;
+    }
+
+    if (lastName?.length) {
+      dbUser.lastName = lastName;
+      changed = true;
+    }
+
+    if (changed) {
+      await dbUser.save();
+    }
+  }
+
+  public async create({
+    username,
+    email,
+    password,
+    firstName = null,
+    lastName = null,
+  }: any): Promise<User> {
+    const dbUser = await $db.User().create({
+      username,
+      email,
+      password: User.hashPassword(password),
+      firstName,
+      lastName,
+    });
+
+    return new User(dbUser.dataValues);
+  }
+}
+
+export default Users;
diff --git a/src/repos/index.ts b/src/repos/index.ts
index 6477ff7..7dced50 100644
--- a/src/repos/index.ts
+++ b/src/repos/index.ts
@@ -1,11 +1,19 @@
-import { Db } from 'src/db';
-import LocationRepository from './LocationRepository';
+import Location from './Location';
+import Users from './Users';
+import UserRoles from './UserRoles';
+import UserSessions from './UserSessions';
 
 class Repositories {
-  public location: LocationRepository;
+  public location: Location;
+  public users: Users;
+  public userRoles: UserRoles;
+  public userSessions: UserSessions;
 
-  constructor(db: Db) {
-    this.location = new LocationRepository(db);
+  constructor() {
+    this.location = new Location();
+    this.users = new Users();
+    this.userRoles = new UserRoles();
+    this.userSessions = new UserSessions();
   }
 }
 
diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts
index 442a775..a1701a6 100644
--- a/src/requests/LocationRequest.ts
+++ b/src/requests/LocationRequest.ts
@@ -2,6 +2,7 @@ import { Op } from 'sequelize';
 
 import { Optional } from 'src/types';
 import { Db } from 'src/db';
+import { ValidationError } from '../errors';
 
 class LocationRequest {
   limit: Optional<number> = 250;
@@ -34,7 +35,7 @@ class LocationRequest {
     if (req[key] != null) {
       const numValue = (this as any)[key] = parseInt(req[key]);
       if (isNaN(numValue)) {
-        throw new TypeError(`Invalid value for ${key}: ${req[key]}`);
+        throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
       }
     }
   }
@@ -44,7 +45,7 @@ class LocationRequest {
       const numValue = (this as any)[key] = parseInt(req[key]);
       const dateValue = (this as any)[key] = new Date(isNaN(numValue) ? req[key] : numValue);
       if (isNaN(dateValue.getTime())) {
-        throw new TypeError(`Invalid value for ${key}: ${req[key]}`);
+        throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
       }
     }
   }
diff --git a/src/routes/Route.ts b/src/routes/Route.ts
index a5e3af6..36ae611 100644
--- a/src/routes/Route.ts
+++ b/src/routes/Route.ts
@@ -1,9 +1,20 @@
 import { Express, Request, Response } from 'express';
 
+import {
+  BadRequest,
+  Forbidden,
+  NotFound,
+  Unauthorized,
+} from '../errors';
+
+import { AuthInfo } from '../auth';
+import { Optional, RequestHandler } from '../types';
 import { logRequest } from '../helpers/logging';
 
 abstract class Route {
   protected readonly path: string;
+  // Method -> Handler mapping
+  public static preRequestHandlers: Record<string, RequestHandler> = {};
 
   constructor(path: string) {
     this.path = path;
@@ -13,52 +24,99 @@ abstract class Route {
     res.status(405).send('Method Not Allowed');
   }
 
-  private getRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
-    logRequest(req);
-    return await this.get(req, res);
+  protected static ServerError(req: Request, res: Response, error: Error) {
+    console.error(`Unhandled error in ${req.method} ${req.path}: ${error}`);
+    res.status(500).send('Internal Server Error');
   }
 
-  private postRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
-    logRequest(req);
-    return await this.post(req, res);
+  protected static handleError(req: Request, res: Response, error: Error) {
+    if (error instanceof Unauthorized) {
+      const redirect = req.query?.redirect || '/';
+      res.redirect(`/login?redirect=${redirect}`);
+      return;
+    }
+
+    if (error instanceof Forbidden) {
+      res.status(403).send(error.message);
+      return;
+    }
+
+    if (error instanceof NotFound) {
+      res.status(404).send(error.message);
+      return;
+    }
+
+    if (error instanceof BadRequest) {
+      res.status(400).send(error.message);
+      return;
+    }
+
+    Route.ServerError(req, res, error);
   }
 
-  private putRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
+  private handleRequest = async (
+    req: Request,
+    res: Response,
+    handlerName: string,
+  ) => {
     logRequest(req);
-    return await this.put(req, res);
+    // @ts-expect-error
+    const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>);
+    const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName];
+
+    try {
+      let authInfo: Optional<AuthInfo>
+      if (preRequestHandler) {
+        authInfo = await preRequestHandler(req, res);
+      }
+
+      handler(req, res, authInfo!);
+    } catch (error) {
+      (<typeof Route> this.constructor).handleError(req, res, error);
+    }
   }
 
-  private deleteRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
-    logRequest(req);
-    return await this.delete(req, res);
+  private getRoute = async (req: Request, res: Response) => {
+    await this.handleRequest(req, res, 'get');
   }
 
-  private patchRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
-    logRequest(req);
-    return await this.patch(req, res);
+  private postRoute = async (req: Request, res: Response) => {
+    await this.handleRequest(req, res, 'post');
   }
 
-  public get: (req: Request, res: Response) => Promise<void> = async (_, res) => {
+  private putRoute = async (req: Request, res: Response) => {
+    await this.handleRequest(req, res, 'put');
+  }
+
+  private deleteRoute = async (req: Request, res: Response) => {
+    await this.handleRequest(req, res, 'delete');
+  }
+
+  private patchRoute = async (req: Request, res: Response) => {
+    await this.handleRequest(req, res, 'patch');
+  }
+
+  public get = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
     Route.NotAllowed(res);
     return Promise.resolve();
   }
 
-  public post: (req: Request, res: Response) => Promise<void> = async (_, res) => {
+  public post = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
     Route.NotAllowed(res);
     return Promise.resolve();
   }
 
-  public put: (req: Request, res: Response) => Promise<void> = async (_, res) => {
+  public put = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
     Route.NotAllowed(res);
     return Promise.resolve();
   }
 
-  public delete: (req: Request, res: Response) => Promise<void> = async (_, res) => {
+  public delete = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
     Route.NotAllowed(res);
     return Promise.resolve();
   }
 
-  public patch: (req: Request, res: Response) => Promise<void> = async (_, res) => {
+  public patch = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
     Route.NotAllowed(res);
     return Promise.resolve();
   }
diff --git a/src/routes/api/Route.ts b/src/routes/api/Route.ts
index af9897b..d9669ad 100644
--- a/src/routes/api/Route.ts
+++ b/src/routes/api/Route.ts
@@ -1,4 +1,7 @@
+import { Request, Response } from 'express';
+
 import Route from '../Route';
+import { Unauthorized, } from '../../errors';
 
 abstract class ApiRoute extends Route {
   protected version: string;
@@ -7,6 +10,16 @@ abstract class ApiRoute extends Route {
     super(ApiRoute.toApiPath(path, version));
   }
 
+  protected static handleError(req: Request, res: Response, error: Error) {
+    // Handle API unauthorized errors with a 401+JSON response instead of a redirect
+    if (error instanceof Unauthorized) {
+      res.status(401).send(error.message);
+      return;
+    }
+
+    return super.handleError(req, res, error);
+  }
+
   protected static toApiPath(path: string, version: string): string {
     if (!path.startsWith('/')) {
       path = `/${path}`;
diff --git a/src/routes/api/v1/Auth.ts b/src/routes/api/v1/Auth.ts
new file mode 100644
index 0000000..e4e2fbe
--- /dev/null
+++ b/src/routes/api/v1/Auth.ts
@@ -0,0 +1,77 @@
+import { Optional } from '../../../types';
+import { Request, Response } from 'express';
+
+import ApiV1Route from './Route';
+import { ValidationError } from '../../../errors';
+import { AuthInfo, authenticate } from '../../../auth';
+
+class Auth extends ApiV1Route {
+  constructor() {
+    super('/auth');
+  }
+
+  /**
+   * Create a new session for the user.
+   *
+   * If the user is already authenticated (either through a cookie or a token),
+   * the existing session will be returned. Otherwise, a new session will be created.
+   *
+   * @param req: Request - The request object.
+   * @param res: Response - The response object.
+   * @param auth: Optional<AuthInfo> - The authentication information.
+   */
+  @authenticate()
+  post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
+    const user = auth!.user;
+    let session = auth!.session;
+    const expiresAt = req.body?.expiresAt;
+    let expiresAtDate: Optional<Date> = null;
+
+    if (session) {
+      console.debug('The user already has an active session or token');
+      res.json({
+        session: {
+          token: session.getToken(),
+        }
+      });
+
+      return;
+    }
+
+    if (expiresAt) {
+      try {
+        expiresAtDate = new Date(expiresAt);
+      } catch (error) {
+        throw new ValidationError(`Invalid expiresAt: ${error}`);
+      }
+    }
+
+    session = await $repos.userSessions.create(user.id, expiresAtDate);
+    res.setHeader('Set-Cookie', `session=${session.getToken()}; Path=/; HttpOnly; SameSite=Strict`);
+    res.json({
+      session: {
+        token: session.getToken(),
+      }
+    });
+  }
+
+  /**
+   * Delete the current session.
+   *
+   * It requires the user to be authenticated (either through a cookie or a token).
+   * If the user is authenticated, the session will be destroyed and the cookie will be cleared.
+   */
+  @authenticate()
+  delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
+    const session = auth!.session;
+
+    if (session) {
+      await session.destroy();
+    }
+
+    res.setHeader('Set-Cookie', 'session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0');
+    res.status(204).send();
+  }
+}
+
+export default Auth;
diff --git a/src/routes/api/v1/GPSData.ts b/src/routes/api/v1/GPSData.ts
index d38e23e..6a5814c 100644
--- a/src/routes/api/v1/GPSData.ts
+++ b/src/routes/api/v1/GPSData.ts
@@ -1,16 +1,15 @@
 import { Request, Response } from 'express';
 
+import { authenticate } from '../../../auth';
 import { LocationRequest } from '../../../requests';
-import LocationRepository from '~/repos/LocationRepository';
 import ApiV1Route from './Route';
 
-const $location: LocationRepository = globalThis.$repos.location;
-
 class GPSData extends ApiV1Route {
   constructor() {
     super('/gpsdata');
   }
 
+  @authenticate()
   get = async (req: Request, res: Response) => {
     let query: LocationRequest
 
@@ -24,7 +23,7 @@ class GPSData extends ApiV1Route {
     }
 
     try {
-      const gpsData = await $location.getHistory(query);
+      const gpsData = await $repos.location.getHistory(query);
       res.json(gpsData);
     } catch (error) {
       const e = `Error fetching data: ${error}`;
diff --git a/src/routes/api/v1/UserSelf.ts b/src/routes/api/v1/UserSelf.ts
new file mode 100644
index 0000000..fee7b28
--- /dev/null
+++ b/src/routes/api/v1/UserSelf.ts
@@ -0,0 +1,30 @@
+import { Optional } from '../../../types';
+import { Request, Response } from 'express';
+
+import ApiV1Route from './Route';
+import { AuthInfo, authenticate } from '../../../auth';
+import { maskPassword } from '../../../helpers/security';
+
+class UserSelf extends ApiV1Route {
+  constructor() {
+    super('/users/me');
+  }
+
+  /**
+   * GET /users/me
+   *
+   * It returns a JSON object with the user and session information.
+   */
+  @authenticate()
+  get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
+    const user = auth!.user;
+    const session = auth!.session;
+
+    res.json({
+      user: maskPassword(user),
+      ...(session ? { session } : {}),
+    });
+  }
+}
+
+export default UserSelf;
diff --git a/src/routes/api/v1/index.ts b/src/routes/api/v1/index.ts
index 0c13d9c..3832ced 100644
--- a/src/routes/api/v1/index.ts
+++ b/src/routes/api/v1/index.ts
@@ -1,8 +1,14 @@
+import Auth from "./Auth";
 import GPSData from "./GPSData";
 import Routes from "../../Routes";
+import UserSelf from "./UserSelf";
 
 class ApiV1Routes extends Routes {
-  public routes = [new GPSData()];
+  public routes = [
+    new Auth(),
+    new GPSData(),
+    new UserSelf(),
+  ];
 }
 
 export default ApiV1Routes;
diff --git a/src/types.ts b/src/types.ts
index dec3aa8..538e401 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,3 +1,11 @@
-type Optional<T> = T | null | undefined;
+import { Request, Response } from 'express';
 
-export { Optional };
+import { AuthInfo } from './auth';
+
+type Optional<T> = T | null | undefined;
+type RequestHandler = (req: Request, res: Response) => Promise<AuthInfo>;
+
+export {
+  Optional,
+  RequestHandler,
+};