diff --git a/gleam.toml b/gleam.toml
index 5e3548f..b41f1f8 100644
--- a/gleam.toml
+++ b/gleam.toml
@@ -20,7 +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"
+gleam_crypto = ">= 1.3.0 and < 2.0.0"
 
 [dev-dependencies]
 gleeunit = ">= 1.0.0 and < 2.0.0"
diff --git a/manifest.toml b/manifest.toml
index cc58daf..3b0b281 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -32,16 +32,15 @@ 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]
 argus = { version = ">= 1.0.0 and < 2.0.0" }
 birl = { version = ">= 1.7.1 and < 2.0.0" }
 decode = { version = ">= 0.2.0 and < 1.0.0" }
+gleam_crypto = { version = ">= 1.3.0 and < 2.0.0" }
 gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" }
 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 897e6cc..c7a4007 100644
--- a/src/pevensie/auth.gleam
+++ b/src/pevensie/auth.gleam
@@ -1,24 +1,25 @@
 import argus
-import birl.{type Time}
-import birl/duration
+import gleam/bit_array
+import gleam/crypto.{Sha256}
 import gleam/dict
 import gleam/dynamic.{type Decoder}
-import gleam/json
+import gleam/io
 import gleam/option.{type Option, None, Some}
 import gleam/result
-import pevensie/cache
+import gleam/string
 import pevensie/drivers.{
   type AuthDriver, type Connected, type Disabled, type Disconnected,
 }
 import pevensie/internal/auth
 import pevensie/internal/encoder.{type Encoder}
 import pevensie/internal/pevensie.{type Pevensie}
+import pevensie/internal/session.{type Session}
 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
+import pevensie/net.{type IpAddress}
 
 pub type User(user_metadata) =
   InternalUser(user_metadata)
@@ -36,8 +37,14 @@ pub fn new_auth_config(
   driver driver: AuthDriver(driver, user_metadata),
   user_metadata_decoder user_metadata_decoder: Decoder(user_metadata),
   user_metadata_encoder user_metadata_encoder: Encoder(user_metadata),
+  cookie_key cookie_key: String,
 ) -> AuthConfig(driver, user_metadata, Disconnected) {
-  auth.AuthConfig(driver, user_metadata_decoder, user_metadata_encoder)
+  auth.AuthConfig(
+    driver:,
+    user_metadata_decoder:,
+    user_metadata_encoder:,
+    cookie_key:,
+  )
 }
 
 pub fn disabled() -> AuthConfig(Nil, user_metadata, Disabled) {
@@ -120,9 +127,10 @@ pub fn create_user_with_email(
   user_metadata: user_metadata,
 ) -> Result(User(user_metadata), Nil) {
   let assert auth.AuthConfig(
-    driver,
-    user_metadata_decoder,
-    user_metadata_encoder,
+    driver:,
+    user_metadata_decoder:,
+    user_metadata_encoder:,
+    ..,
   ) = pevensie.auth_config
 
   // TODO: Handle errors
@@ -161,6 +169,7 @@ pub fn set_user_role(
     driver:,
     user_metadata_decoder:,
     user_metadata_encoder:,
+    ..,
   ) = pevensie.auth_config
 
   driver.update_user(
@@ -173,72 +182,29 @@ pub fn set_user_role(
   )
 }
 
-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,
+    cache_status,
   ),
   user_id: String,
+  ip: Option(IpAddress),
+  user_agent: Option(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)),
-    )
+  let assert auth.AuthConfig(driver, ..) = pevensie.auth_config
 
-  use _ <- result.try(cache.store(
-    pevensie,
-    session_resource_type,
-    session.id,
-    session
-      |> encode_session
-      |> json.to_string,
-    Some(ttl_seconds),
-  ))
-
-  Ok(session)
+  driver.create_session(
+    driver.driver,
+    user_id,
+    ip,
+    user_agent,
+    ttl_seconds,
+    False,
+  )
 }
 
 pub fn get_session(
@@ -247,25 +213,13 @@ pub fn get_session(
     auth_driver,
     Connected,
     cache_driver,
-    Connected,
+    cache_status,
   ),
   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))
-    }
-  }
+  let assert auth.AuthConfig(driver, ..) = pevensie.auth_config
+
+  driver.get_session(driver.driver, session_id, None, None)
 }
 
 pub fn delete_session(
@@ -278,8 +232,9 @@ pub fn delete_session(
   ),
   session_id: String,
 ) -> Result(Nil, Nil) {
-  use _ <- result.try(cache.delete(pevensie, session_resource_type, session_id))
-  Ok(Nil)
+  let assert auth.AuthConfig(driver, ..) = pevensie.auth_config
+
+  driver.delete_session(driver.driver, session_id)
 }
 
 pub fn log_in_user(
@@ -292,12 +247,62 @@ pub fn log_in_user(
   ),
   email: String,
   password: String,
+  ip: Option(IpAddress),
+  user_agent: Option(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))
+  create_session(pevensie, user.id, ip, user_agent, Some(24 * 60 * 60))
   |> result.map(fn(session) { #(session, user) })
 }
+
+fn sha256_hash(data: String, key: String) -> Result(String, Nil) {
+  let key = bit_array.from_string(key)
+  let data = bit_array.from_string(data)
+  Ok(crypto.sign_message(data, key, Sha256))
+}
+
+pub fn create_cookie(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    cache_status,
+  ),
+  session: Session,
+) -> Result(String, Nil) {
+  let assert auth.AuthConfig(cookie_key:, ..) = pevensie.auth_config
+  io.println("Creating hash")
+  use hash <- result.try(sha256_hash(session.id, cookie_key))
+  io.println("Hash created")
+  Ok(session.id <> "|" <> hash)
+}
+
+// TODO: Improve errors
+pub fn verify_cookie(
+  pevensie: Pevensie(
+    user_metadata,
+    auth_driver,
+    Connected,
+    cache_driver,
+    cache_status,
+  ),
+  cookie: String,
+) -> Result(String, Nil) {
+  let assert auth.AuthConfig(cookie_key:, ..) = pevensie.auth_config
+  let cookie_parts = string.split(cookie, "|")
+  case cookie_parts {
+    [session_id, hash_string] -> {
+      use new_hash <- result.try(sha256_hash(session_id, cookie_key))
+      case new_hash == hash_string {
+        True -> Ok(session_id)
+        False -> Error(Nil)
+      }
+    }
+    _ -> Error(Nil)
+  }
+}
diff --git a/src/pevensie/drivers.gleam b/src/pevensie/drivers.gleam
index 047d648..e723898 100644
--- a/src/pevensie/drivers.gleam
+++ b/src/pevensie/drivers.gleam
@@ -1,7 +1,9 @@
 import gleam/dynamic.{type Decoder}
 import gleam/option.{type Option}
 import pevensie/internal/encoder.{type Encoder}
+import pevensie/internal/session.{type Session}
 import pevensie/internal/user.{type User, type UserInsert, type UserUpdate}
+import pevensie/net.{type IpAddress}
 
 pub type Connected
 
@@ -64,6 +66,35 @@ type DeleteUserFunction(auth_driver, user_metadata) =
   fn(auth_driver, String, String, Decoder(user_metadata)) ->
     Result(User(user_metadata), Nil)
 
+/// A function that gets a session by ID.
+/// Args:
+///   - auth_driver: The auth driver to use.
+///   - session_id: The ID of the session to get.
+///   - ip: The IP address of the user.
+///   - user_agent: The user agent of the user.
+type GetSessionFunction(auth_driver) =
+  fn(auth_driver, String, Option(IpAddress), Option(String)) ->
+    Result(Option(Session), Nil)
+
+/// A function that creates a new session for a user.
+/// Args:
+///   - auth_driver: The auth driver to use.
+///   - user_id: The ID of the user to create a session for.
+///   - ip: The IP address of the user.
+///   - user_agent: The user agent of the user.
+///   - ttl_seconds: The number of seconds the session should last for.
+///   - delete_other_sessions: Whether to delete any other sessions for the user.
+type CreateSessionFunction(auth_driver) =
+  fn(auth_driver, String, Option(IpAddress), Option(String), Option(Int), Bool) ->
+    Result(Session, Nil)
+
+/// A function that deletes a session by ID.
+/// Args:
+///   - auth_driver: The auth driver to use.
+///   - session_id: The ID of the session to delete.
+type DeleteSessionFunction(auth_driver) =
+  fn(auth_driver, String) -> Result(Nil, Nil)
+
 pub type AuthDriver(driver, user_metadata) {
   AuthDriver(
     driver: driver,
@@ -73,6 +104,9 @@ pub type AuthDriver(driver, user_metadata) {
     insert_user: InsertUserFunction(driver, user_metadata),
     update_user: UpdateUserFunction(driver, user_metadata),
     delete_user: DeleteUserFunction(driver, user_metadata),
+    get_session: GetSessionFunction(driver),
+    create_session: CreateSessionFunction(driver),
+    delete_session: DeleteSessionFunction(driver),
   )
 }
 
diff --git a/src/pevensie/drivers/postgres.gleam b/src/pevensie/drivers/postgres.gleam
index 5492ac8..0c8b6c2 100644
--- a/src/pevensie/drivers/postgres.gleam
+++ b/src/pevensie/drivers/postgres.gleam
@@ -18,10 +18,12 @@ import pevensie/drivers.{
   type AuthDriver, type CacheDriver, AuthDriver, CacheDriver,
 }
 import pevensie/internal/encoder.{type Encoder}
+import pevensie/internal/session.{type Session, Session}
 import pevensie/internal/user.{
   type UpdateField, type User, type UserInsert, type UserUpdate, Ignore, Set,
   User, app_metadata_to_json,
 }
+import pevensie/net.{type IpAddress}
 
 pub type IpVersion {
   Ipv4
@@ -139,6 +141,44 @@ pub fn new_auth_driver(
         Nil
       })
     },
+    get_session: fn(driver, session_id, ip, user_agent) {
+      get_session(driver, session_id, ip, user_agent)
+      // TODO: Handle errors
+      |> result.map_error(fn(err) {
+        io.debug(err)
+        Nil
+      })
+    },
+    create_session: fn(
+      driver,
+      user_id,
+      ip,
+      user_agent,
+      ttl_seconds,
+      delete_other_sessions,
+    ) {
+      create_session(
+        driver,
+        user_id,
+        ip,
+        user_agent,
+        ttl_seconds,
+        delete_other_sessions,
+      )
+      // TODO: Handle errors
+      |> result.map_error(fn(err) {
+        io.debug(err)
+        Nil
+      })
+    },
+    delete_session: fn(driver, session_id) {
+      delete_session(driver, session_id)
+      // TODO: Handle errors
+      |> result.map_error(fn(err) {
+        io.debug(err)
+        Nil
+      })
+    },
   )
 }
 
@@ -487,6 +527,12 @@ fn update_user(
     })
     |> string.join(", ")
 
+  // Add the updated_at field to the list of fields to update
+  let field_setters = case field_setters {
+    "" -> "updated_at = now()"
+    _ -> field_setters <> ", updated_at = now()"
+  }
+
   let update_values =
     fields_to_update
     |> list.map(pair.second)
@@ -499,9 +545,6 @@ fn update_user(
     ) <> " and deleted_at is null
     returning " <> user_select_fields
 
-  sql
-  |> io.debug
-
   let query_result =
     pgo.execute(
       sql,
@@ -552,13 +595,261 @@ fn delete_user(
   }
 }
 
+const session_select_fields = "
+  id::text,
+  user_id::text,
+  (extract(epoch from created_at) * 1000000)::bigint as created_at,
+  (extract(epoch from expires_at) * 1000000)::bigint as expires_at,
+  host(ip)::text,
+  user_agent
+"
+
+fn postgres_session_decoder() -> Decoder(Session) {
+  fn(data) {
+    io.debug(data)
+    decode.into({
+      use id <- decode.parameter
+      use user_id <- decode.parameter
+      use created_at_tuple <- decode.parameter
+      use expires_at_tuple <- decode.parameter
+      use ip_string <- decode.parameter
+      use user_agent <- decode.parameter
+
+      let created_at = birl.from_unix_micro(created_at_tuple)
+      let expires_at = case expires_at_tuple {
+        None -> None
+        Some(expires_at_tuple) -> Some(birl.from_unix_micro(expires_at_tuple))
+      }
+
+      use ip <- result.try(case ip_string {
+        None -> Ok(None)
+        Some(ip_string) -> {
+          net.parse_ip_address(ip_string)
+          |> result.map_error(fn(_) {
+            [
+              dynamic.DecodeError(
+                expected: "IP address",
+                found: ip_string,
+                path: [],
+              ),
+            ]
+          })
+          |> result.map(Some)
+        }
+      })
+
+      Ok(Session(id:, created_at:, expires_at:, user_id:, ip:, user_agent:))
+    })
+    |> decode.field(0, decode.string)
+    |> decode.field(1, decode.string)
+    |> decode.field(2, decode.int)
+    |> decode.field(3, decode.optional(decode.int))
+    |> decode.field(4, decode.optional(decode.string))
+    |> decode.field(5, decode.optional(decode.string))
+    |> decode.from(data)
+    |> result.flatten
+  }
+}
+
+pub fn get_session(
+  driver: Postgres,
+  session_id: String,
+  ip: Option(IpAddress),
+  user_agent: Option(String),
+) -> Result(Option(Session), PostgresError) {
+  let assert Postgres(_, Some(conn)) = driver
+
+  let sql = "
+    select
+      " <> session_select_fields <> "
+    from pevensie.\"session\"
+    where id = $1
+    "
+
+  let additional_fields = [
+    #(
+      case ip {
+        None -> "ip is $"
+        Some(_) -> "ip = $"
+      },
+      pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)),
+    ),
+    #(
+      case user_agent {
+        None -> "user_agent is $"
+        Some(_) -> "user_agent = $"
+      },
+      pgo.nullable(pgo.text, user_agent),
+    ),
+  ]
+
+  let sql =
+    list.index_fold(additional_fields, sql, fn(sql, field, index) {
+      sql <> " and " <> field.0 <> int.to_string(index + 2)
+    })
+
+  let query_result =
+    pgo.execute(
+      sql,
+      conn,
+      [
+        pgo.text(session_id),
+        pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)),
+        pgo.nullable(pgo.text, user_agent),
+      ],
+      postgres_session_decoder(),
+    )
+    // TODO: Handle errors
+    |> result.map_error(QueryError)
+
+  use response <- result.try(query_result)
+  case response.rows {
+    [] -> Ok(None)
+    [session] -> Ok(Some(session))
+    _ -> Error(InternalError("Unexpected number of rows returned"))
+  }
+}
+
+fn delete_sessions_for_user(
+  driver: Postgres,
+  user_id: String,
+  except ignored_session_id: String,
+) -> Result(Nil, PostgresError) {
+  let assert Postgres(_, Some(conn)) = driver
+
+  let sql =
+    "
+    delete from pevensie.\"session\"
+    where user_id = $1 and id != $2
+    returning id
+  "
+
+  let query_result =
+    pgo.execute(
+      sql,
+      conn,
+      [pgo.text(user_id), pgo.text(ignored_session_id)],
+      fn(_) { Ok(json.null()) },
+    )
+    // TODO: Handle errors
+    |> result.map_error(QueryError)
+
+  case query_result {
+    Ok(_) -> Ok(Nil)
+    Error(err) -> Error(err)
+  }
+}
+
+pub fn create_session(
+  driver: Postgres,
+  user_id: String,
+  ip: Option(IpAddress),
+  user_agent: Option(String),
+  ttl_seconds: Option(Int),
+  delete_other_sessions: Bool,
+) -> Result(Session, PostgresError) {
+  let assert Postgres(_, Some(conn)) = driver
+
+  let expires_at_sql = case ttl_seconds {
+    None -> "null"
+    Some(ttl_seconds) ->
+      "now() + interval '" <> int.to_string(ttl_seconds) <> " seconds'"
+  }
+
+  // inet is a weird type and doesn't work with pgo,
+  // so we have to cast it to text.
+  // This is fine because the `IpAddress` type is guaranteed
+  // to be a valid IP address, so there's no chance of
+  // SQL injection.
+  let ip_string = case ip {
+    None -> "null"
+    Some(ip) -> "'" <> net.format_ip_address(ip) <> "'::inet"
+  }
+
+  let _ =
+    net.parse_ip_address("127.0.0.1")
+    |> io.debug
+
+  let sql = "
+    insert into pevensie.\"session\" (
+      user_id,
+      ip,
+      user_agent,
+      expires_at
+    ) values (
+      $1,
+      " <> ip_string <> ",
+      $2,
+      " <> expires_at_sql <> "
+    )
+    returning
+      " <> session_select_fields
+
+  let query_result =
+    pgo.execute(
+      sql,
+      conn,
+      [
+        pgo.text(user_id),
+        // pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)),
+        pgo.nullable(pgo.text, user_agent),
+      ],
+      postgres_session_decoder(),
+    )
+    // TODO: Handle errors
+    |> result.map_error(QueryError)
+
+  use response <- result.try(query_result)
+  case response.rows {
+    [] -> Error(NotFound)
+    [session] -> {
+      case delete_other_sessions {
+        True -> {
+          use _ <- result.try(delete_sessions_for_user(
+            driver,
+            user_id,
+            session.id,
+          ))
+          Ok(session)
+        }
+        False -> Ok(session)
+      }
+    }
+    _ -> Error(InternalError("Unexpected number of rows returned"))
+  }
+}
+
+pub fn delete_session(
+  driver: Postgres,
+  session_id: String,
+) -> Result(Nil, PostgresError) {
+  let assert Postgres(_, Some(conn)) = driver
+
+  let sql =
+    "
+    delete from pevensie.\"session\"
+    where id = $1
+    returning id
+  "
+
+  let query_result =
+    pgo.execute(sql, conn, [pgo.text(session_id)], fn(_) { Ok(json.null()) })
+    // TODO: Handle errors
+    |> result.map_error(QueryError)
+
+  case query_result {
+    Ok(_) -> Ok(Nil)
+    Error(err) -> Error(err)
+  }
+}
+
 pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) {
   CacheDriver(
     driver: Postgres(config |> postgres_config_to_pgo_config, None),
     connect: connect,
     disconnect: disconnect,
     store: fn(driver, resource_type, key, value, ttl_seconds) {
-      store(driver, resource_type, key, value, ttl_seconds)
+      store_in_cache(driver, resource_type, key, value, ttl_seconds)
       // TODO: Handle errors
       |> result.map_error(fn(err) {
         io.debug(err)
@@ -566,7 +857,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) {
       })
     },
     get: fn(driver, resource_type, key) {
-      get(driver, resource_type, key)
+      get_from_cache(driver, resource_type, key)
       // TODO: Handle errors
       |> result.map_error(fn(err) {
         io.debug(err)
@@ -574,7 +865,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) {
       })
     },
     delete: fn(driver, resource_type, key) {
-      delete(driver, resource_type, key)
+      delete_from_cache(driver, resource_type, key)
       // TODO: Handle errors
       |> result.map_error(fn(err) {
         io.debug(err)
@@ -584,7 +875,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) {
   )
 }
 
-fn store(
+fn store_in_cache(
   driver: Postgres,
   resource_type: String,
   key: String,
@@ -628,7 +919,7 @@ fn store(
   }
 }
 
-fn get(
+fn get_from_cache(
   driver: Postgres,
   resource_type: String,
   key: String,
@@ -668,14 +959,17 @@ fn get(
     // If the value has expired, return None and delete the key
     // in an async task
     [#(_, True)] -> {
-      process.start(fn() { delete(driver, resource_type, key) }, False)
+      process.start(
+        fn() { delete_from_cache(driver, resource_type, key) },
+        False,
+      )
       Ok(None)
     }
     _ -> Error(InternalError("Unexpected number of rows returned"))
   }
 }
 
-fn delete(
+fn delete_from_cache(
   driver: Postgres,
   resource_type: String,
   key: String,
diff --git a/src/pevensie/internal/auth.gleam b/src/pevensie/internal/auth.gleam
index 79506dd..ea93a04 100644
--- a/src/pevensie/internal/auth.gleam
+++ b/src/pevensie/internal/auth.gleam
@@ -7,6 +7,7 @@ pub type AuthConfig(driver, user_metadata, connected) {
     driver: AuthDriver(driver, user_metadata),
     user_metadata_decoder: Decoder(user_metadata),
     user_metadata_encoder: Encoder(user_metadata),
+    cookie_key: String,
   )
   AuthDisabled
 }
diff --git a/src/pevensie/internal/pevensie.gleam b/src/pevensie/internal/pevensie.gleam
index 49e9ee1..3d76e5c 100644
--- a/src/pevensie/internal/pevensie.gleam
+++ b/src/pevensie/internal/pevensie.gleam
@@ -61,7 +61,12 @@ pub fn connect_auth(
   Nil,
 ) {
   let assert Pevensie(
-    AuthConfig(auth_driver, user_metadata_decoder, user_metadata_encoder),
+    AuthConfig(
+      driver: auth_driver,
+      user_metadata_decoder:,
+      user_metadata_encoder:,
+      cookie_key:,
+    ),
     cache_config,
   ) = pevensie
 
@@ -72,6 +77,7 @@ pub fn connect_auth(
         driver: AuthDriver(..auth_driver, driver: internal_driver),
         user_metadata_decoder:,
         user_metadata_encoder:,
+        cookie_key:,
       ),
       cache_config:,
     )
@@ -91,7 +97,12 @@ pub fn disconnect_auth(
   Nil,
 ) {
   let assert Pevensie(
-    AuthConfig(auth_driver, user_metadata_decoder, user_metadata_encoder),
+    AuthConfig(
+      driver: auth_driver,
+      user_metadata_decoder:,
+      user_metadata_encoder:,
+      cookie_key:,
+    ),
     cache_config,
   ) = pevensie
 
@@ -102,6 +113,7 @@ pub fn disconnect_auth(
         driver: AuthDriver(..auth_driver, driver: internal_driver),
         user_metadata_decoder:,
         user_metadata_encoder:,
+        cookie_key:,
       ),
       cache_config:,
     )
diff --git a/src/pevensie/internal/session.gleam b/src/pevensie/internal/session.gleam
new file mode 100644
index 0000000..18c8517
--- /dev/null
+++ b/src/pevensie/internal/session.gleam
@@ -0,0 +1,14 @@
+import birl.{type Time}
+import gleam/option.{type Option}
+import pevensie/net.{type IpAddress}
+
+pub type Session {
+  Session(
+    id: String,
+    created_at: Time,
+    expires_at: Option(Time),
+    user_id: String,
+    ip: Option(IpAddress),
+    user_agent: Option(String),
+  )
+}
diff --git a/src/pevensie/net.gleam b/src/pevensie/net.gleam
new file mode 100644
index 0000000..0d9e2a8
--- /dev/null
+++ b/src/pevensie/net.gleam
@@ -0,0 +1,59 @@
+import gleam/erlang/atom.{type Atom}
+import gleam/erlang/charlist.{type Charlist}
+import gleam/int
+import gleam/list
+import gleam/result
+import gleam/string
+
+pub type IpAddress {
+  IpV4(Int, Int, Int, Int)
+  IpV6(Int, Int, Int, Int, Int, Int, Int, Int)
+}
+
+// These accept Erlang `string()`s, which are not the same as Gleam `String`s.
+// We convert them to Erlang `string()`s (equivalent to Gleam `List(String)`s
+// where each element is a single character) before passing them to the
+// Erlang function.
+@external(erlang, "inet", "parse_ipv4_address")
+fn inet_parse_ipv4(ip: Charlist) -> Result(#(Int, Int, Int, Int), Atom)
+
+@external(erlang, "inet", "parse_ipv6_address")
+fn inet_parse_ipv6(
+  ip: Charlist,
+) -> Result(#(Int, Int, Int, Int, Int, Int, Int, Int), Atom)
+
+fn parse_ipv4(ip: String) -> Result(IpAddress, Nil) {
+  ip
+  |> charlist.from_string
+  |> inet_parse_ipv4
+  |> result.map(fn(ip) { IpV4(ip.0, ip.1, ip.2, ip.3) })
+  |> result.replace_error(Nil)
+}
+
+fn parse_ipv6(ip: String) -> Result(IpAddress, Nil) {
+  ip
+  |> charlist.from_string
+  |> inet_parse_ipv6
+  |> result.map(fn(ip) { IpV6(ip.0, ip.1, ip.2, ip.3, ip.4, ip.5, ip.6, ip.7) })
+  |> result.replace_error(Nil)
+}
+
+pub fn parse_ip_address(ip: String) -> Result(IpAddress, Nil) {
+  parse_ipv4(ip)
+  |> result.lazy_or(fn() { parse_ipv6(ip) })
+}
+
+pub fn format_ip_address(ip: IpAddress) -> String {
+  case ip {
+    IpV4(a, b, c, d) -> {
+      [a, b, c, d]
+      |> list.map(int.to_string)
+      |> string.join(".")
+    }
+    IpV6(a, b, c, d, e, f, g, h) -> {
+      [a, b, c, d, e, f, g, h]
+      |> list.map(int.to_string)
+      |> string.join(":")
+    }
+  }
+}
diff --git a/v1.sql b/v1.sql
index 0320ae8..fcd9dd3 100644
--- a/v1.sql
+++ b/v1.sql
@@ -50,8 +50,9 @@ language plpgsql
 
 create schema if not exists pevensie;
 
+-- user
 create table if not exists pevensie."user" (
-  id uuid not null default uuid7(),
+  id uuid not null default uuid7() primary key,
   created_at timestamptz not null default now(),
   updated_at timestamptz not null default now(),
   deleted_at timestamptz,
@@ -67,9 +68,20 @@ create table if not exists pevensie."user" (
   banned_until timestamptz
 );
 
-create unique index user_email_unique_idx on "user" (email, deleted_at) where (email is not null) nulls not distinct;
+create unique index user_email_unique_idx on pevensie."user" (email, deleted_at) nulls not distinct where (email is not null);
 
-create table if not exists pevensie."cache" (
+-- session
+create table if not exists pevensie."session" (
+  id uuid not null default uuid7() primary key,
+  created_at timestamptz not null default now(),
+  expires_at timestamptz,
+  user_id uuid not null references pevensie."user"(id),
+  ip inet,
+  user_agent text
+);
+
+-- cache
+create unlogged table if not exists pevensie."cache" (
   resource_type text not null,
   key text not null unique,
   value text not null,