diff --git a/gleam.toml b/gleam.toml
index 15a736e..5e3548f 100644
--- a/gleam.toml
+++ b/gleam.toml
@@ -20,6 +20,7 @@ gleam_pgo = ">= 0.13.0 and < 1.0.0"
 birl = ">= 1.7.1 and < 2.0.0"
 decode = ">= 0.2.0 and < 1.0.0"
 gleam_json = ">= 1.0.1 and < 2.0.0"
+youid = ">= 1.2.0 and < 2.0.0"
 
 [dev-dependencies]
 gleeunit = ">= 1.0.0 and < 2.0.0"
diff --git a/manifest.toml b/manifest.toml
index 9f67f5f..cc58daf 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -12,6 +12,7 @@ packages = [
   { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" },
   { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" },
   { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" },
+  { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" },
   { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
   { name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" },
   { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
@@ -31,6 +32,7 @@ packages = [
   { name = "startest", version = "0.5.0", build_tools = ["gleam"], requirements = ["argv", "bigben", "birl", "exception", "gleam_community_ansi", "gleam_erlang", "gleam_javascript", "gleam_stdlib", "glint", "simplifile", "tom"], otp_app = "startest", source = "hex", outer_checksum = "7A4BE0A1674D1DEB421B0BEB1E90B1A9C4AB21D954CF1754C0E28EA6B5DBE785" },
   { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
   { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" },
+  { name = "youid", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "youid", source = "hex", outer_checksum = "EF0F693004E221155EE5909C6D3C945DD14F7117DBA882887CF5F45BE399B8CA" },
 ]
 
 [requirements]
@@ -42,3 +44,4 @@ gleam_json = { version = ">= 1.0.1 and < 2.0.0" }
 gleam_pgo = { version = ">= 0.13.0 and < 1.0.0" }
 gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
 gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+youid = { version = ">= 1.2.0 and < 2.0.0" }
diff --git a/src/pevensie/auth.gleam b/src/pevensie/auth.gleam
index b55b15b..897e6cc 100644
--- a/src/pevensie/auth.gleam
+++ b/src/pevensie/auth.gleam
@@ -1,19 +1,24 @@
 import argus
+import birl.{type Time}
+import birl/duration
 import gleam/dict
 import gleam/dynamic.{type Decoder}
+import gleam/json
 import gleam/option.{type Option, None, Some}
 import gleam/result
+import pevensie/cache
 import pevensie/drivers.{
   type AuthDriver, type Connected, type Disabled, type Disconnected,
-  type Encoder,
 }
 import pevensie/internal/auth
+import pevensie/internal/encoder.{type Encoder}
 import pevensie/internal/pevensie.{type Pevensie}
 import pevensie/internal/user.{
   type User as InternalUser, type UserInsert as UserInsertInternal,
   type UserUpdate as UserUpdateInternal, Set, UserInsert, UserUpdate,
   default_user_update,
 }
+import youid/uuid
 
 pub type User(user_metadata) =
   InternalUser(user_metadata)
@@ -76,7 +81,7 @@ fn hash_password(password: String) {
   |> argus.hash(password, argus.gen_salt())
 }
 
-pub fn verify_email_and_password(
+pub fn get_user_by_email_and_password(
   pevensie: Pevensie(
     user_metadata,
     auth_driver,
@@ -167,3 +172,132 @@ pub fn set_user_role(
     user_metadata_encoder,
   )
 }
+
+pub type Session {
+  Session(id: String, user_id: String, created_at: Time, expires_at: Time)
+}
+
+fn encode_session(session: Session) -> json.Json {
+  json.object([
+    #("id", json.string(session.id)),
+    #("user_id", json.string(session.user_id)),
+    #("created_at", json.string(birl.to_iso8601(session.created_at))),
+    #("expires_at", json.string(birl.to_iso8601(session.expires_at))),
+  ])
+}
+
+fn session_decoder() -> Decoder(Session) {
+  let time_decoder = fn(time) {
+    use time <- result.try(dynamic.string(time))
+    birl.parse(time)
+    |> result.replace_error([])
+  }
+
+  dynamic.decode4(
+    Session,
+    dynamic.field("id", dynamic.string),
+    dynamic.field("user_id", dynamic.string),
+    dynamic.field("created_at", time_decoder),
+    dynamic.field("expires_at", time_decoder),
+  )
+}
+
+const session_resource_type = "pevensie:session"
+
+pub fn create_session(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    Connected,
+  ),
+  user_id: String,
+  ttl_seconds: Option(Int),
+) -> Result(Session, Nil) {
+  // Check if the user exists
+  use user <- result.try(get_user_by_id(pevensie, user_id))
+
+  let ttl_seconds = option.unwrap(ttl_seconds, 24 * 60 * 60)
+  let now = birl.now()
+  let session =
+    Session(
+      id: uuid.v7_string(),
+      user_id: user.id,
+      created_at: now,
+      expires_at: birl.add(now, duration.seconds(ttl_seconds)),
+    )
+
+  use _ <- result.try(cache.store(
+    pevensie,
+    session_resource_type,
+    session.id,
+    session
+      |> encode_session
+      |> json.to_string,
+    Some(ttl_seconds),
+  ))
+
+  Ok(session)
+}
+
+pub fn get_session(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    Connected,
+  ),
+  session_id: String,
+) -> Result(Option(Session), Nil) {
+  use session <- result.try(cache.get(
+    pevensie,
+    session_resource_type,
+    session_id,
+  ))
+  case session {
+    None -> Ok(None)
+    Some(session_string) -> {
+      use decoded_session <- result.try(
+        json.decode(session_string, session_decoder())
+        |> result.replace_error(Nil),
+      )
+      Ok(Some(decoded_session))
+    }
+  }
+}
+
+pub fn delete_session(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    Connected,
+  ),
+  session_id: String,
+) -> Result(Nil, Nil) {
+  use _ <- result.try(cache.delete(pevensie, session_resource_type, session_id))
+  Ok(Nil)
+}
+
+pub fn log_in_user(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    Connected,
+  ),
+  email: String,
+  password: String,
+) -> Result(#(Session, User(user_metadata)), Nil) {
+  use user <- result.try(get_user_by_email_and_password(
+    pevensie,
+    email,
+    password,
+  ))
+  create_session(pevensie, user.id, Some(24 * 60 * 60))
+  |> result.map(fn(session) { #(session, user) })
+}
diff --git a/src/pevensie/drivers.gleam b/src/pevensie/drivers.gleam
index 15992a6..047d648 100644
--- a/src/pevensie/drivers.gleam
+++ b/src/pevensie/drivers.gleam
@@ -1,6 +1,6 @@
 import gleam/dynamic.{type Decoder}
-import gleam/json
 import gleam/option.{type Option}
+import pevensie/internal/encoder.{type Encoder}
 import pevensie/internal/user.{type User, type UserInsert, type UserUpdate}
 
 pub type Connected
@@ -9,9 +9,6 @@ pub type Disconnected
 
 pub type Disabled
 
-pub type Encoder(a) =
-  fn(a) -> json.Json
-
 /// A function that connects the auth driver. This may
 /// set up any connections or perform any other setup
 /// required to make the driver ready to use.
diff --git a/src/pevensie/drivers/postgres.gleam b/src/pevensie/drivers/postgres.gleam
index 8b82582..5492ac8 100644
--- a/src/pevensie/drivers/postgres.gleam
+++ b/src/pevensie/drivers/postgres.gleam
@@ -15,8 +15,9 @@ import gleam/pgo.{type QueryError as PgoQueryError}
 import gleam/result
 import gleam/string
 import pevensie/drivers.{
-  type AuthDriver, type CacheDriver, type Encoder, AuthDriver, CacheDriver,
+  type AuthDriver, type CacheDriver, AuthDriver, CacheDriver,
 }
+import pevensie/internal/encoder.{type Encoder}
 import pevensie/internal/user.{
   type UpdateField, type User, type UserInsert, type UserUpdate, Ignore, Set,
   User, app_metadata_to_json,
@@ -266,20 +267,20 @@ fn postgres_user_decoder(
       }
 
       Ok(User(
-        id,
-        created_at,
-        updated_at,
-        deleted_at,
-        role,
-        email,
-        password_hash,
-        email_confirmed_at,
-        phone_number,
-        phone_number_confirmed_at,
-        last_sign_in,
-        app_metadata,
-        user_metadata,
-        banned_until,
+        id:,
+        created_at:,
+        updated_at:,
+        deleted_at:,
+        role:,
+        email:,
+        password_hash:,
+        email_confirmed_at:,
+        phone_number:,
+        phone_number_confirmed_at:,
+        last_sign_in:,
+        app_metadata:,
+        user_metadata:,
+        banned_until:,
       ))
     })
     |> decode.field(0, decode.string)
diff --git a/src/pevensie/internal/auth.gleam b/src/pevensie/internal/auth.gleam
index 9784736..79506dd 100644
--- a/src/pevensie/internal/auth.gleam
+++ b/src/pevensie/internal/auth.gleam
@@ -1,5 +1,6 @@
 import gleam/dynamic.{type Decoder}
-import pevensie/drivers.{type AuthDriver, type Encoder}
+import pevensie/drivers.{type AuthDriver}
+import pevensie/internal/encoder.{type Encoder}
 
 pub type AuthConfig(driver, user_metadata, connected) {
   AuthConfig(
diff --git a/src/pevensie/internal/encoder.gleam b/src/pevensie/internal/encoder.gleam
new file mode 100644
index 0000000..aef0d88
--- /dev/null
+++ b/src/pevensie/internal/encoder.gleam
@@ -0,0 +1,4 @@
+import gleam/json
+
+pub type Encoder(a) =
+  fn(a) -> json.Json
diff --git a/src/pevensie/internal/user.gleam b/src/pevensie/internal/user.gleam
index d427684..4d35ab9 100644
--- a/src/pevensie/internal/user.gleam
+++ b/src/pevensie/internal/user.gleam
@@ -3,6 +3,7 @@ import gleam/dict.{type Dict}
 import gleam/dynamic.{type Dynamic}
 import gleam/json
 import gleam/option.{type Option}
+import pevensie/internal/encoder.{type Encoder}
 
 pub type User(user_metadata) {
   User(
@@ -14,7 +15,7 @@ pub type User(user_metadata) {
     email: String,
     password_hash: Option(String),
     email_confirmed_at: Option(Time),
-    phome_number: Option(String),
+    phone_number: Option(String),
     phone_number_confirmed_at: Option(Time),
     last_sign_in: Option(Time),
     app_metadata: Dict(String, Dynamic),
@@ -74,3 +75,52 @@ pub fn app_metadata_to_json(_app_metadata: Dict(String, Dynamic)) -> json.Json {
   // TODO: Properly type app_metadata
   json.object([])
 }
+
+pub fn user_encoder(
+  user: User(user_metadata),
+  user_metadata_encoder: Encoder(user_metadata),
+) -> json.Json {
+  json.object([
+    #("id", json.string(user.id)),
+    #("created_at", json.string(birl.to_iso8601(user.created_at))),
+    #("updated_at", json.string(birl.to_iso8601(user.updated_at))),
+    #(
+      "deleted_at",
+      json.nullable(user.deleted_at |> option.map(birl.to_iso8601), json.string),
+    ),
+    #("role", json.nullable(user.role, json.string)),
+    #("email", json.string(user.email)),
+    #("password_hash", json.nullable(user.password_hash, json.string)),
+    #(
+      "email_confirmed_at",
+      json.nullable(
+        user.email_confirmed_at |> option.map(birl.to_iso8601),
+        json.string,
+      ),
+    ),
+    #("phone_number", json.nullable(user.phone_number, json.string)),
+    #(
+      "phone_number_confirmed_at",
+      json.nullable(
+        user.phone_number_confirmed_at |> option.map(birl.to_iso8601),
+        json.string,
+      ),
+    ),
+    #(
+      "last_sign_in",
+      json.nullable(
+        user.last_sign_in |> option.map(birl.to_iso8601),
+        json.string,
+      ),
+    ),
+    #("app_metadata", app_metadata_to_json(user.app_metadata)),
+    #("user_metadata", user_metadata_encoder(user.user_metadata)),
+    #(
+      "banned_until",
+      json.nullable(
+        user.banned_until |> option.map(birl.to_iso8601),
+        json.string,
+      ),
+    ),
+  ])
+}