diff --git a/migrations/2021_03_02_040000_change_access_tokens_add_type.php b/migrations/2021_03_02_040000_change_access_tokens_add_type.php
new file mode 100644
index 000000000..f80af1f23
--- /dev/null
+++ b/migrations/2021_03_02_040000_change_access_tokens_add_type.php
@@ -0,0 +1,43 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Schema\Builder;
+
+return [
+    'up' => function (Builder $schema) {
+        $schema->table('access_tokens', function (Blueprint $table) {
+            $table->string('type', 100)->index();
+        });
+
+        // Since all active sessions will stop working on update due to switching from user_id to access_token
+        // We can do things simple here by terminating all tokens that have the previously default lifetime
+        $schema->getConnection()->table('access_tokens')
+            ->where('lifetime_seconds', 3600)
+            ->delete();
+
+        // We will then assume that all remaining tokens are remember tokens
+        // This will include tokens that previously had a custom lifetime
+        $schema->getConnection()->table('access_tokens')
+            ->update([
+                'type' => 'session_remember',
+            ]);
+
+        $schema->table('access_tokens', function (Blueprint $table) {
+            $table->dropColumn('lifetime_seconds');
+        });
+    },
+
+    'down' => function (Builder $schema) {
+        $schema->table('access_tokens', function (Blueprint $table) {
+            $table->dropColumn('type');
+            $table->integer('lifetime_seconds');
+        });
+    }
+];
diff --git a/migrations/2021_03_02_040500_change_access_tokens_add_id.php b/migrations/2021_03_02_040500_change_access_tokens_add_id.php
new file mode 100644
index 000000000..06d1ec248
--- /dev/null
+++ b/migrations/2021_03_02_040500_change_access_tokens_add_id.php
@@ -0,0 +1,35 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Schema\Builder;
+
+return [
+    'up' => function (Builder $schema) {
+        $schema->table('access_tokens', function (Blueprint $table) {
+            // Replace primary key with unique index so we can create a new primary
+            $table->dropPrimary('token');
+            $table->unique('token');
+        });
+
+        // This needs to be done in a second statement because of the order Laravel runs operations in
+        $schema->table('access_tokens', function (Blueprint $table) {
+            // Introduce new increment-based ID
+            $table->increments('id')->first();
+        });
+    },
+
+    'down' => function (Builder $schema) {
+        $schema->table('access_tokens', function (Blueprint $table) {
+            $table->dropColumn('id');
+            $table->dropIndex('token');
+            $table->primary('token');
+        });
+    }
+];
diff --git a/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php b/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php
new file mode 100644
index 000000000..885237efb
--- /dev/null
+++ b/migrations/2021_03_02_041000_change_access_tokens_add_title_ip_agent.php
@@ -0,0 +1,21 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+use Flarum\Database\Migration;
+
+return Migration::addColumns('access_tokens', [
+    'title' => ['string', 'length' => 150, 'nullable' => true],
+    // Accommodates both IPv4 and IPv6 as strings
+    'last_ip_address' => ['string', 'length' => 45, 'nullable' => true],
+    // Technically, there's no limit to a user agent length
+    // Most are around 150 in length, and the general recommendation seems to be below 200
+    // We're going to use the longest string possible to be safe
+    // There will still be exceptions, we'll just truncate them
+    'last_user_agent' => ['string', 'length' => 255, 'nullable' => true],
+]);
diff --git a/src/Api/Controller/CreateTokenController.php b/src/Api/Controller/CreateTokenController.php
index 0f3c5710c..8490cb90d 100644
--- a/src/Api/Controller/CreateTokenController.php
+++ b/src/Api/Controller/CreateTokenController.php
@@ -9,7 +9,8 @@
 
 namespace Flarum\Api\Controller;
 
-use Flarum\Http\AccessToken;
+use Flarum\Http\RememberAccessToken;
+use Flarum\Http\SessionAccessToken;
 use Flarum\User\Exception\NotAuthenticatedException;
 use Flarum\User\UserRepository;
 use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
@@ -66,8 +67,20 @@ class CreateTokenController implements RequestHandlerInterface
             throw new NotAuthenticatedException;
         }
 
-        $token = AccessToken::generate($user->id, $lifetime);
-        $token->save();
+        // Use of lifetime attribute is deprecated in beta 16, removed in beta 17
+        // For backward compatibility with custom integrations, longer lifetimes will be interpreted as remember tokens
+        if ($lifetime > 3600 || Arr::get($body, 'remember')) {
+            if ($lifetime > 3600) {
+                trigger_error('Use of parameter lifetime is deprecated in beta 16, will be removed in beta 17. Use remember parameter to start a remember session', E_USER_DEPRECATED);
+            }
+
+            $token = RememberAccessToken::generate($user->id);
+        } else {
+            $token = SessionAccessToken::generate($user->id);
+        }
+
+        // We do a first update here to log the IP/agent of the token creator, even if the token is never used afterwards
+        $token->touch($request);
 
         return new JsonResponse([
             'token' => $token->token,
diff --git a/src/Forum/Auth/ResponseFactory.php b/src/Forum/Auth/ResponseFactory.php
index af2dc9245..ba1839440 100644
--- a/src/Forum/Auth/ResponseFactory.php
+++ b/src/Forum/Auth/ResponseFactory.php
@@ -9,6 +9,7 @@
 
 namespace Flarum\Forum\Auth;
 
+use Flarum\Http\RememberAccessToken;
 use Flarum\Http\Rememberer;
 use Flarum\User\LoginProvider;
 use Flarum\User\RegistrationToken;
@@ -75,6 +76,8 @@ class ResponseFactory
     {
         $response = $this->makeResponse(['loggedIn' => true]);
 
-        return $this->rememberer->rememberUser($response, $user->id);
+        $token = RememberAccessToken::generate($user->id);
+
+        return $this->rememberer->remember($response, $token);
     }
 }
diff --git a/src/Forum/Controller/ConfirmEmailController.php b/src/Forum/Controller/ConfirmEmailController.php
index f8bfe9083..f57572ae2 100644
--- a/src/Forum/Controller/ConfirmEmailController.php
+++ b/src/Forum/Controller/ConfirmEmailController.php
@@ -9,6 +9,7 @@
 
 namespace Flarum\Forum\Controller;
 
+use Flarum\Http\SessionAccessToken;
 use Flarum\Http\SessionAuthenticator;
 use Flarum\Http\UrlGenerator;
 use Flarum\User\Command\ConfirmEmail;
@@ -61,7 +62,8 @@ class ConfirmEmailController implements RequestHandlerInterface
         );
 
         $session = $request->getAttribute('session');
-        $this->authenticator->logIn($session, $user->id);
+        $token = SessionAccessToken::generate($user->id);
+        $this->authenticator->logIn($session, $token);
 
         return new RedirectResponse($this->url->to('forum')->base());
     }
diff --git a/src/Forum/Controller/LogInController.php b/src/Forum/Controller/LogInController.php
index 587e01693..ba0378981 100644
--- a/src/Forum/Controller/LogInController.php
+++ b/src/Forum/Controller/LogInController.php
@@ -12,6 +12,7 @@ namespace Flarum\Forum\Controller;
 use Flarum\Api\Client;
 use Flarum\Api\Controller\CreateTokenController;
 use Flarum\Http\AccessToken;
+use Flarum\Http\RememberAccessToken;
 use Flarum\Http\Rememberer;
 use Flarum\Http\SessionAuthenticator;
 use Flarum\User\Event\LoggedIn;
@@ -71,21 +72,21 @@ class LogInController implements RequestHandlerInterface
     {
         $actor = $request->getAttribute('actor');
         $body = $request->getParsedBody();
-        $params = Arr::only($body, ['identification', 'password']);
+        $params = Arr::only($body, ['identification', 'password', 'remember']);
 
         $response = $this->apiClient->send(CreateTokenController::class, $actor, [], $params);
 
         if ($response->getStatusCode() === 200) {
             $data = json_decode($response->getBody());
 
-            $session = $request->getAttribute('session');
-            $this->authenticator->logIn($session, $data->userId);
+            $token = AccessToken::findValid($data->token);
 
-            $token = AccessToken::find($data->token);
+            $session = $request->getAttribute('session');
+            $this->authenticator->logIn($session, $token);
 
             $this->events->dispatch(new LoggedIn($this->users->findOrFail($data->userId), $token));
 
-            if (Arr::get($body, 'remember')) {
+            if ($token instanceof RememberAccessToken) {
                 $response = $this->rememberer->remember($response, $token);
             }
         }
diff --git a/src/Forum/Controller/RegisterController.php b/src/Forum/Controller/RegisterController.php
index db0ef3bc7..3e6684341 100644
--- a/src/Forum/Controller/RegisterController.php
+++ b/src/Forum/Controller/RegisterController.php
@@ -11,6 +11,7 @@ namespace Flarum\Forum\Controller;
 
 use Flarum\Api\Client;
 use Flarum\Api\Controller\CreateUserController;
+use Flarum\Http\RememberAccessToken;
 use Flarum\Http\Rememberer;
 use Flarum\Http\SessionAuthenticator;
 use Psr\Http\Message\ResponseInterface;
@@ -62,10 +63,12 @@ class RegisterController implements RequestHandlerInterface
         if (isset($body->data)) {
             $userId = $body->data->id;
 
-            $session = $request->getAttribute('session');
-            $this->authenticator->logIn($session, $userId);
+            $token = RememberAccessToken::generate($userId);
 
-            $response = $this->rememberer->rememberUser($response, $userId);
+            $session = $request->getAttribute('session');
+            $this->authenticator->logIn($session, $token);
+
+            $response = $this->rememberer->remember($response, $token);
         }
 
         return $response;
diff --git a/src/Forum/Controller/SavePasswordController.php b/src/Forum/Controller/SavePasswordController.php
index 4e6f5ea1e..2148ff34b 100644
--- a/src/Forum/Controller/SavePasswordController.php
+++ b/src/Forum/Controller/SavePasswordController.php
@@ -10,6 +10,7 @@
 namespace Flarum\Forum\Controller;
 
 use Flarum\Foundation\DispatchEventsTrait;
+use Flarum\Http\SessionAccessToken;
 use Flarum\Http\SessionAuthenticator;
 use Flarum\Http\UrlGenerator;
 use Flarum\User\PasswordToken;
@@ -99,7 +100,8 @@ class SavePasswordController implements RequestHandlerInterface
         $token->delete();
 
         $session = $request->getAttribute('session');
-        $this->authenticator->logIn($session, $token->user->id);
+        $accessToken = SessionAccessToken::generate($token->user->id);
+        $this->authenticator->logIn($session, $accessToken);
 
         return new RedirectResponse($this->url->to('forum')->base());
     }
diff --git a/src/Http/AccessToken.php b/src/Http/AccessToken.php
index 0c0e97cdf..c641c6577 100644
--- a/src/Http/AccessToken.php
+++ b/src/Http/AccessToken.php
@@ -12,53 +12,112 @@ namespace Flarum\Http;
 use Carbon\Carbon;
 use Flarum\Database\AbstractModel;
 use Flarum\User\User;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Arr;
 use Illuminate\Support\Str;
+use Psr\Http\Message\ServerRequestInterface;
 
 /**
+ * @property int $id
  * @property string $token
  * @property int $user_id
  * @property Carbon $created_at
  * @property Carbon|null $last_activity_at
- * @property int $lifetime_seconds
+ * @property string $type
+ * @property string $title
+ * @property string $last_ip_address
+ * @property string $last_user_agent
  * @property \Flarum\User\User|null $user
  */
 class AccessToken extends AbstractModel
 {
+    protected $table = 'access_tokens';
+
+    protected $dates = [
+        'created_at',
+        'last_activity_at',
+    ];
+
     /**
-     * Use a custom primary key for this model.
+     * A map of access token types, as specified in the `type` column, to their classes.
      *
-     * @var bool
+     * @var array
      */
-    public $incrementing = false;
+    protected static $models = [];
 
-    protected $primaryKey = 'token';
+    /**
+     * The type of token this is, to be stored in the access tokens table.
+     *
+     * Should be overwritten by subclasses with the value that is
+     * to be stored in the database, which will then be used for
+     * mapping the hydrated model instance to the proper subtype.
+     *
+     * @var string
+     */
+    public static $type = '';
 
-    protected $dates = ['last_activity_at'];
+    /**
+     * How long this access token should be valid from the time of last activity.
+     * This value will be used in the validity and expiration checks.
+     * @var int Lifetime in seconds. Zero means it will never expire.
+     */
+    protected static $lifetime = 0;
 
     /**
      * Generate an access token for the specified user.
      *
      * @param int $userId
-     * @param int $lifetime
+     * @param int $lifetime Does nothing. Deprecated in beta 16, removed in beta 17
      * @return static
      */
-    public static function generate($userId, $lifetime = 3600)
+    public static function generate($userId, $lifetime = null)
     {
-        $token = new static;
+        if (! is_null($lifetime)) {
+            trigger_error('Parameter $lifetime is deprecated in beta 16, will be removed in beta 17', E_USER_DEPRECATED);
+        }
+
+        if (static::class === self::class) {
+            trigger_error('Use of AccessToken::generate() is deprecated in beta 16. Use SessionAccessToken::generate() or RememberAccessToken::generate()', E_USER_DEPRECATED);
+
+            $token = new SessionAccessToken;
+            $token->type = 'session';
+        } else {
+            $token = new static;
+            $token->type = static::$type;
+        }
 
         $token->token = Str::random(40);
         $token->user_id = $userId;
         $token->created_at = Carbon::now();
         $token->last_activity_at = Carbon::now();
-        $token->lifetime_seconds = $lifetime;
+        $token->save();
 
         return $token;
     }
 
-    public function touch()
+    /**
+     * Update the time of last usage of a token.
+     * If a request object is provided, the IP address and User Agent will also be logged.
+     * @param ServerRequestInterface|null $request
+     * @return bool
+     */
+    public function touch(ServerRequestInterface $request = null)
     {
         $this->last_activity_at = Carbon::now();
 
+        if ($request) {
+            $this->last_ip_address = $request->getAttribute('ipAddress');
+            // We truncate user agent so it fits in the database column
+            // The length is hard-coded as the column length
+            // It seems like MySQL or Laravel already truncates values, but we'll play safe and do it ourselves
+            $this->last_user_agent = substr(Arr::get($request->getServerParams(), 'HTTP_USER_AGENT'), 0, 255);
+        } else {
+            // If no request is provided, we set the values back to null
+            // That way the values always match with the date logged in last_activity
+            $this->last_ip_address = null;
+            $this->last_user_agent = null;
+        }
+
         return $this->save();
     }
 
@@ -71,4 +130,133 @@ class AccessToken extends AbstractModel
     {
         return $this->belongsTo(User::class);
     }
+
+    /**
+     * Filters which tokens are valid at the given date for this particular token type.
+     * Uses the static::$lifetime value by default, can be overridden by children classes.
+     * @param Builder $query
+     * @param Carbon $date
+     */
+    protected static function scopeValid(Builder $query, Carbon $date)
+    {
+        if (static::$lifetime > 0) {
+            $query->where('last_activity_at', '>', $date->clone()->subSeconds(static::$lifetime));
+        }
+    }
+
+    /**
+     * Filters which tokens are expired at the given date and ready for garbage collection.
+     * Uses the static::$lifetime value by default, can be overridden by children classes.
+     * @param Builder $query
+     * @param Carbon $date
+     */
+    protected static function scopeExpired(Builder $query, Carbon $date)
+    {
+        if (static::$lifetime > 0) {
+            $query->where('last_activity_at', '<', $date->clone()->subSeconds(static::$lifetime));
+        } else {
+            $query->whereRaw('FALSE');
+        }
+    }
+
+    /**
+     * Shortcut to find a valid token.
+     * @param string $token Token as sent by the user. We allow non-string values like null so we can directly feed any value from a request.
+     * @return AccessToken|null
+     */
+    public static function findValid($token): ?AccessToken
+    {
+        return static::query()->whereValid()->where('token', $token)->first();
+    }
+
+    /**
+     * This query scope is intended to be used on the base AccessToken object to query for valid tokens of any type.
+     * @param Builder $query
+     * @param Carbon|null $date
+     */
+    public function scopeWhereValid(Builder $query, Carbon $date = null)
+    {
+        if (is_null($date)) {
+            $date = Carbon::now();
+        }
+
+        $query->where(function (Builder $query) use ($date) {
+            foreach ($this->getModels() as $model) {
+                $query->orWhere(function (Builder $query) use ($model, $date) {
+                    $query->where('type', $model::$type);
+                    $model::scopeValid($query, $date);
+                });
+            }
+        });
+    }
+
+    /**
+     * This query scope is intended to be used on the base AccessToken object to query for expired tokens of any type.
+     * @param Builder $query
+     * @param Carbon|null $date
+     */
+    public function scopeWhereExpired(Builder $query, Carbon $date = null)
+    {
+        if (is_null($date)) {
+            $date = Carbon::now();
+        }
+
+        $query->where(function (Builder $query) use ($date) {
+            foreach ($this->getModels() as $model) {
+                $query->orWhere(function (Builder $query) use ($model, $date) {
+                    $query->where('type', $model::$type);
+                    $model::scopeExpired($query, $date);
+                });
+            }
+        });
+    }
+
+    /**
+     * Create a new model instance according to the access token type.
+     *
+     * @param array $attributes
+     * @param string|null $connection
+     * @return static|object
+     */
+    public function newFromBuilder($attributes = [], $connection = null)
+    {
+        $attributes = (array) $attributes;
+
+        if (! empty($attributes['type'])
+            && isset(static::$models[$attributes['type']])
+            && class_exists($class = static::$models[$attributes['type']])
+        ) {
+            /** @var AccessToken $instance */
+            $instance = new $class;
+            $instance->exists = true;
+            $instance->setRawAttributes($attributes, true);
+            $instance->setConnection($connection ?: $this->connection);
+
+            return $instance;
+        }
+
+        return parent::newFromBuilder($attributes, $connection);
+    }
+
+    /**
+     * Get the type-to-model map.
+     *
+     * @return array
+     */
+    public static function getModels()
+    {
+        return static::$models;
+    }
+
+    /**
+     * Set the model for the given access token type.
+     *
+     * @param string $type The access token type.
+     * @param string $model The class name of the model for that type.
+     * @return void
+     */
+    public static function setModel(string $type, string $model)
+    {
+        static::$models[$type] = $model;
+    }
 }
diff --git a/src/Http/DeveloperAccessToken.php b/src/Http/DeveloperAccessToken.php
new file mode 100644
index 000000000..751a836a8
--- /dev/null
+++ b/src/Http/DeveloperAccessToken.php
@@ -0,0 +1,17 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Http;
+
+class DeveloperAccessToken extends AccessToken
+{
+    public static $type = 'developer';
+
+    protected static $lifetime = 0;
+}
diff --git a/src/Http/HttpServiceProvider.php b/src/Http/HttpServiceProvider.php
index 0fd218200..e1aab7035 100644
--- a/src/Http/HttpServiceProvider.php
+++ b/src/Http/HttpServiceProvider.php
@@ -62,4 +62,25 @@ class HttpServiceProvider extends AbstractServiceProvider
             return new SlugManager($this->app->make('flarum.http.selectedSlugDrivers'));
         });
     }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function boot()
+    {
+        $this->setAccessTokenTypes();
+    }
+
+    protected function setAccessTokenTypes()
+    {
+        $models = [
+            DeveloperAccessToken::class,
+            RememberAccessToken::class,
+            SessionAccessToken::class
+        ];
+
+        foreach ($models as $model) {
+            AccessToken::setModel($model::$type, $model);
+        }
+    }
 }
diff --git a/src/Http/Middleware/AuthenticateWithHeader.php b/src/Http/Middleware/AuthenticateWithHeader.php
index 90e8bee40..225ea92b8 100644
--- a/src/Http/Middleware/AuthenticateWithHeader.php
+++ b/src/Http/Middleware/AuthenticateWithHeader.php
@@ -39,8 +39,8 @@ class AuthenticateWithHeader implements Middleware
 
                 $request = $request->withAttribute('apiKey', $key);
                 $request = $request->withAttribute('bypassThrottling', true);
-            } elseif ($token = AccessToken::find($id)) {
-                $token->touch();
+            } elseif ($token = AccessToken::findValid($id)) {
+                $token->touch($request);
 
                 $actor = $token->user;
             }
diff --git a/src/Http/Middleware/AuthenticateWithSession.php b/src/Http/Middleware/AuthenticateWithSession.php
index ce30f9997..98b7a9b04 100644
--- a/src/Http/Middleware/AuthenticateWithSession.php
+++ b/src/Http/Middleware/AuthenticateWithSession.php
@@ -9,8 +9,8 @@
 
 namespace Flarum\Http\Middleware;
 
+use Flarum\Http\AccessToken;
 use Flarum\User\Guest;
-use Flarum\User\User;
 use Illuminate\Contracts\Session\Session;
 use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
@@ -23,7 +23,7 @@ class AuthenticateWithSession implements Middleware
     {
         $session = $request->getAttribute('session');
 
-        $actor = $this->getActor($session);
+        $actor = $this->getActor($session, $request);
 
         $actor->setSession($session);
 
@@ -32,14 +32,25 @@ class AuthenticateWithSession implements Middleware
         return $handler->handle($request);
     }
 
-    private function getActor(Session $session)
+    private function getActor(Session $session, Request $request)
     {
-        $actor = User::find($session->get('user_id')) ?: new Guest;
+        if ($session->has('access_token')) {
+            $token = AccessToken::findValid($session->get('access_token'));
 
-        if ($actor->exists) {
-            $actor->updateLastSeen()->save();
+            if ($token) {
+                $actor = $token->user;
+                $actor->updateLastSeen()->save();
+
+                $token->touch($request);
+
+                return $actor;
+            }
+
+            // If this session used to have a token which is no longer valid we properly refresh the session
+            $session->invalidate();
+            $session->regenerateToken();
         }
 
-        return $actor;
+        return new Guest;
     }
 }
diff --git a/src/Http/Middleware/CollectGarbage.php b/src/Http/Middleware/CollectGarbage.php
index c84f61f37..c20330891 100644
--- a/src/Http/Middleware/CollectGarbage.php
+++ b/src/Http/Middleware/CollectGarbage.php
@@ -56,7 +56,7 @@ class CollectGarbage implements Middleware
 
         $time = Carbon::now()->timestamp;
 
-        AccessToken::whereRaw('last_activity_at <= ? - lifetime_seconds', [$time])->delete();
+        AccessToken::whereExpired()->delete();
 
         $earliestToKeep = date('Y-m-d H:i:s', $time - 24 * 60 * 60);
 
diff --git a/src/Http/Middleware/RememberFromCookie.php b/src/Http/Middleware/RememberFromCookie.php
index e9293f097..07ac1b227 100644
--- a/src/Http/Middleware/RememberFromCookie.php
+++ b/src/Http/Middleware/RememberFromCookie.php
@@ -11,6 +11,7 @@ namespace Flarum\Http\Middleware;
 
 use Flarum\Http\AccessToken;
 use Flarum\Http\CookieFactory;
+use Flarum\Http\RememberAccessToken;
 use Illuminate\Support\Arr;
 use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
@@ -37,14 +38,14 @@ class RememberFromCookie implements Middleware
         $id = Arr::get($request->getCookieParams(), $this->cookie->getName('remember'));
 
         if ($id) {
-            $token = AccessToken::find($id);
+            $token = AccessToken::findValid($id);
 
-            if ($token) {
+            if ($token && $token instanceof RememberAccessToken) {
                 $token->touch();
 
                 /** @var \Illuminate\Contracts\Session\Session $session */
                 $session = $request->getAttribute('session');
-                $session->put('user_id', $token->user_id);
+                $session->put('access_token', $token->token);
             }
         }
 
diff --git a/src/Http/RememberAccessToken.php b/src/Http/RememberAccessToken.php
new file mode 100644
index 000000000..9a091fb04
--- /dev/null
+++ b/src/Http/RememberAccessToken.php
@@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Http;
+
+class RememberAccessToken extends AccessToken
+{
+    public static $type = 'session_remember';
+
+    protected static $lifetime = 5 * 365 * 24 * 60 * 60; // 5 years
+
+    /**
+     * Just a helper method so we can re-use the lifetime value which is protected.
+     * @return int
+     */
+    public static function rememberCookieLifeTime(): int
+    {
+        return self::$lifetime;
+    }
+}
diff --git a/src/Http/Rememberer.php b/src/Http/Rememberer.php
index 69fdc42b4..607537ad1 100644
--- a/src/Http/Rememberer.php
+++ b/src/Http/Rememberer.php
@@ -29,20 +29,36 @@ class Rememberer
         $this->cookie = $cookie;
     }
 
+    /**
+     * Sets the remember cookie on a response.
+     * @param ResponseInterface $response
+     * @param RememberAccessToken $token The remember token to set on the response. Use of non-remember token is deprecated in beta 16, removed eta 17.
+     * @return ResponseInterface
+     */
     public function remember(ResponseInterface $response, AccessToken $token)
     {
-        $token->lifetime_seconds = 5 * 365 * 24 * 60 * 60; // 5 years
-        $token->save();
+        if (! ($token instanceof RememberAccessToken)) {
+            trigger_error('Parameter $token of type AccessToken is deprecated in beta 16, must be instance of RememberAccessToken in beta 17', E_USER_DEPRECATED);
+
+            $token->type = 'session_remember';
+            $token->save();
+        }
 
         return FigResponseCookies::set(
             $response,
-            $this->cookie->make(self::COOKIE_NAME, $token->token, $token->lifetime_seconds)
+            $this->cookie->make(self::COOKIE_NAME, $token->token, RememberAccessToken::rememberCookieLifeTime())
         );
     }
 
+    /**
+     * @param ResponseInterface $response
+     * @param $userId
+     * @return ResponseInterface
+     * @deprecated beta 16, removed beta 17. Use remember() with a token
+     */
     public function rememberUser(ResponseInterface $response, $userId)
     {
-        $token = AccessToken::generate($userId);
+        $token = RememberAccessToken::generate($userId);
 
         return $this->remember($response, $token);
     }
diff --git a/src/Http/SessionAccessToken.php b/src/Http/SessionAccessToken.php
new file mode 100644
index 000000000..4cf162efb
--- /dev/null
+++ b/src/Http/SessionAccessToken.php
@@ -0,0 +1,17 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Http;
+
+class SessionAccessToken extends AccessToken
+{
+    public static $type = 'session';
+
+    protected static $lifetime = 60 * 60;  // 1 hour
+}
diff --git a/src/Http/SessionAuthenticator.php b/src/Http/SessionAuthenticator.php
index 0e26f80aa..b85f891a6 100644
--- a/src/Http/SessionAuthenticator.php
+++ b/src/Http/SessionAuthenticator.php
@@ -15,12 +15,22 @@ class SessionAuthenticator
 {
     /**
      * @param Session $session
-     * @param int $userId
+     * @param AccessToken|int $token Token or user ID. Use of User ID is deprecated in beta 16, will be removed in beta 17
      */
-    public function logIn(Session $session, $userId)
+    public function logIn(Session $session, $token)
     {
+        // Backwards compatibility with $userId as parameter
+        // Remove in beta 17
+        if (! ($token instanceof AccessToken)) {
+            $userId = $token;
+
+            trigger_error('Parameter $userId is deprecated in beta 16, will be replaced by $token in beta 17', E_USER_DEPRECATED);
+
+            $token = SessionAccessToken::generate($userId);
+        }
+
         $session->regenerate(true);
-        $session->put('user_id', $userId);
+        $session->put('access_token', $token->token);
     }
 
     /**
@@ -28,6 +38,12 @@ class SessionAuthenticator
      */
     public function logOut(Session $session)
     {
+        $token = AccessToken::findValid($session->get('access_token'));
+
+        if ($token) {
+            $token->delete();
+        }
+
         $session->invalidate();
         $session->regenerateToken();
     }
diff --git a/src/Install/Controller/InstallController.php b/src/Install/Controller/InstallController.php
index f56e0e75d..a226afb35 100644
--- a/src/Install/Controller/InstallController.php
+++ b/src/Install/Controller/InstallController.php
@@ -9,6 +9,8 @@
 
 namespace Flarum\Install\Controller;
 
+use Flarum\Http\RememberAccessToken;
+use Flarum\Http\Rememberer;
 use Flarum\Http\SessionAuthenticator;
 use Flarum\Install\AdminUser;
 use Flarum\Install\BaseUrl;
@@ -35,15 +37,22 @@ class InstallController implements RequestHandlerInterface
      */
     protected $authenticator;
 
+    /**
+     * @var Rememberer
+     */
+    protected $rememberer;
+
     /**
      * InstallController constructor.
      * @param Installation $installation
      * @param SessionAuthenticator $authenticator
+     * @param Rememberer $rememberer
      */
-    public function __construct(Installation $installation, SessionAuthenticator $authenticator)
+    public function __construct(Installation $installation, SessionAuthenticator $authenticator, Rememberer $rememberer)
     {
         $this->installation = $installation;
         $this->authenticator = $authenticator;
+        $this->rememberer = $rememberer;
     }
 
     /**
@@ -55,11 +64,15 @@ class InstallController implements RequestHandlerInterface
         $input = $request->getParsedBody();
         $baseUrl = BaseUrl::fromUri($request->getUri());
 
+        // An access token we will use to auto-login the admin at the end of installation
+        $accessToken = Str::random(40);
+
         try {
             $pipeline = $this->installation
                 ->baseUrl($baseUrl)
                 ->databaseConfig($this->makeDatabaseConfig($input))
                 ->adminUser($this->makeAdminUser($input))
+                ->accessToken($accessToken)
                 ->settings([
                     'forum_title' => Arr::get($input, 'forumTitle'),
                     'mail_from' => $baseUrl->toEmail('noreply'),
@@ -77,9 +90,13 @@ class InstallController implements RequestHandlerInterface
         }
 
         $session = $request->getAttribute('session');
-        $this->authenticator->logIn($session, 1);
+        // Because the Eloquent models cannot be used yet, we create a temporary in-memory object
+        // that won't interact with the database but can be passed to the authenticator and rememberer
+        $token = new RememberAccessToken();
+        $token->token = $accessToken;
+        $this->authenticator->logIn($session, $token);
 
-        return new Response\EmptyResponse;
+        return $this->rememberer->remember(new Response\EmptyResponse, $token);
     }
 
     private function makeDatabaseConfig(array $input): DatabaseConfig
diff --git a/src/Install/Installation.php b/src/Install/Installation.php
index 8ddd58250..0ee3e5248 100644
--- a/src/Install/Installation.php
+++ b/src/Install/Installation.php
@@ -29,6 +29,8 @@ class Installation
     /** @var AdminUser */
     private $adminUser;
 
+    private $accessToken;
+
     // A few instance variables to persist objects between steps.
     // Could also be local variables in build(), but this way
     // access in closures is easier. :)
@@ -83,6 +85,13 @@ class Installation
         return $this;
     }
 
+    public function accessToken(string $token)
+    {
+        $this->accessToken = $token;
+
+        return $this;
+    }
+
     public function prerequisites(): Prerequisite\PrerequisiteInterface
     {
         return new Prerequisite\Composite(
@@ -135,7 +144,7 @@ class Installation
         });
 
         $pipeline->pipe(function () {
-            return new Steps\CreateAdminUser($this->db, $this->adminUser);
+            return new Steps\CreateAdminUser($this->db, $this->adminUser, $this->accessToken);
         });
 
         $pipeline->pipe(function () {
diff --git a/src/Install/Steps/CreateAdminUser.php b/src/Install/Steps/CreateAdminUser.php
index aede9a74a..f80d30a8c 100644
--- a/src/Install/Steps/CreateAdminUser.php
+++ b/src/Install/Steps/CreateAdminUser.php
@@ -9,6 +9,7 @@
 
 namespace Flarum\Install\Steps;
 
+use Carbon\Carbon;
 use Flarum\Group\Group;
 use Flarum\Install\AdminUser;
 use Flarum\Install\Step;
@@ -26,10 +27,16 @@ class CreateAdminUser implements Step
      */
     private $admin;
 
-    public function __construct(ConnectionInterface $database, AdminUser $admin)
+    /**
+     * @var string|null
+     */
+    private $accessToken;
+
+    public function __construct(ConnectionInterface $database, AdminUser $admin, string $accessToken = null)
     {
         $this->database = $database;
         $this->admin = $admin;
+        $this->accessToken = $accessToken;
     }
 
     public function getMessage()
@@ -47,5 +54,15 @@ class CreateAdminUser implements Step
             'user_id' => $uid,
             'group_id' => Group::ADMINISTRATOR_ID,
         ]);
+
+        if ($this->accessToken) {
+            $this->database->table('access_tokens')->insert([
+                'type' => 'session_remember',
+                'token' => $this->accessToken,
+                'user_id' => $uid,
+                'created_at' => Carbon::now(),
+                'last_activity_at' => Carbon::now(),
+            ]);
+        }
     }
 }
diff --git a/tests/integration/BuildsHttpRequests.php b/tests/integration/BuildsHttpRequests.php
index 410d67b56..26fd34743 100644
--- a/tests/integration/BuildsHttpRequests.php
+++ b/tests/integration/BuildsHttpRequests.php
@@ -45,7 +45,7 @@ trait BuildsHttpRequests
             'user_id' => $userId,
             'created_at' => Carbon::now()->toDateTimeString(),
             'last_activity_at' => Carbon::now()->toDateTimeString(),
-            'lifetime_seconds' => 3600
+            'type' => 'session'
         ]);
 
         return $req->withAddedHeader('Authorization', "Token {$token}");
diff --git a/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php b/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php
new file mode 100644
index 000000000..3782566cc
--- /dev/null
+++ b/tests/integration/api/access_tokens/AccessTokenLifecycleTest.php
@@ -0,0 +1,143 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Tests\integration\api\access_tokens;
+
+use Carbon\Carbon;
+use Flarum\Http\AccessToken;
+use Flarum\Tests\integration\RetrievesAuthorizedUsers;
+use Flarum\Tests\integration\TestCase;
+use Laminas\Diactoros\ServerRequest;
+
+class AccessTokenLifecycleTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+
+    /**
+     * @inheritDoc
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->prepareDatabase([
+            'access_tokens' => [
+                ['token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'],
+                ['token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'],
+                ['token' => 'c', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'developer'],
+            ],
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function tokens_expire()
+    {
+        $this->populateDatabase();
+
+        // 30 minutes after last activity
+        $this->assertEquals([], AccessToken::whereExpired(Carbon::parse('2021-01-01 02:30:00'))->pluck('token')->all());
+
+        // 1h30 after last activity
+        $this->assertEquals(['a'], AccessToken::whereExpired(Carbon::parse('2021-01-01 03:30:00'))->pluck('token')->all());
+
+        // 6 years after last activity
+        $this->assertEquals(['a', 'b'], AccessToken::whereExpired(Carbon::parse('2027-01-01 01:00:00'))->pluck('token')->sort()->values()->all());
+    }
+
+    /**
+     * @test
+     */
+    public function tokens_valid()
+    {
+        $this->populateDatabase();
+
+        // 30 minutes after last activity
+        $this->assertEquals(['a', 'b', 'c'], AccessToken::whereValid(Carbon::parse('2021-01-01 02:30:00'))->pluck('token')->sort()->values()->all());
+
+        // 1h30 after last activity
+        $this->assertEquals(['b', 'c'], AccessToken::whereValid(Carbon::parse('2021-01-01 03:30:00'))->pluck('token')->sort()->values()->all());
+
+        // 6 years after last activity
+        $this->assertEquals(['c'], AccessToken::whereValid(Carbon::parse('2027-01-01 01:00:00'))->pluck('token')->all());
+    }
+
+    /**
+     * @test
+     */
+    public function touch_updates_lifetime()
+    {
+        $this->populateDatabase();
+
+        // 45 minutes after last activity
+        Carbon::setTestNow('2021-01-01 02:45:00');
+        $token = AccessToken::findValid('a');
+        $this->assertNotNull($token);
+        $token->touch();
+        Carbon::setTestNow();
+
+        // 1h30 after original last activity, 45 minutes after touch
+        $this->assertTrue(AccessToken::whereValid(Carbon::parse('2021-01-01 03:30:00'))->whereToken('a')->exists());
+    }
+
+    /**
+     * @test
+     */
+    public function touch_without_request()
+    {
+        $this->populateDatabase();
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $token->touch();
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $this->assertNull($token->last_ip_address);
+        $this->assertNull($token->last_user_agent);
+    }
+
+    /**
+     * @test
+     */
+    public function touch_with_request()
+    {
+        $this->populateDatabase();
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $token->touch((new ServerRequest([
+            'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36',
+        ]))->withAttribute('ipAddress', '8.8.8.8'));
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $this->assertEquals('8.8.8.8', $token->last_ip_address);
+        $this->assertEquals('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36', $token->last_user_agent);
+    }
+
+    /**
+     * @test
+     */
+    public function long_user_agent_id_truncated()
+    {
+        $this->populateDatabase();
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $token->touch(new ServerRequest([
+            'HTTP_USER_AGENT' => str_repeat('a', 500),
+        ]));
+
+        /** @var AccessToken $token */
+        $token = AccessToken::whereToken('a')->firstOrFail();
+        $this->assertEquals(255, strlen($token->last_user_agent));
+    }
+}
diff --git a/tests/integration/api/access_tokens/RemembererTest.php b/tests/integration/api/access_tokens/RemembererTest.php
new file mode 100644
index 000000000..56056a170
--- /dev/null
+++ b/tests/integration/api/access_tokens/RemembererTest.php
@@ -0,0 +1,97 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Tests\integration\api\access_tokens;
+
+use Carbon\Carbon;
+use Flarum\Tests\integration\RetrievesAuthorizedUsers;
+use Flarum\Tests\integration\TestCase;
+
+class RemembererTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+
+    /**
+     * @inheritDoc
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->prepareDatabase([
+            'access_tokens' => [
+                ['token' => 'a', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session'],
+                ['token' => 'b', 'user_id' => 1, 'last_activity_at' => Carbon::parse('2021-01-01 02:00:00'), 'type' => 'session_remember'],
+            ],
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function non_remember_tokens_cannot_be_used()
+    {
+        $this->populateDatabase();
+
+        Carbon::setTestNow('2021-01-01 02:30:00');
+
+        $response = $this->send(
+            $this->request('GET', '/api')->withCookieParams([
+                'flarum_remember' => 'a',
+            ])
+        );
+
+        Carbon::setTestNow();
+
+        $data = json_decode($response->getBody(), true);
+        $this->assertFalse($data['data']['attributes']['canViewUserList']);
+    }
+
+    /**
+     * @test
+     */
+    public function expired_tokens_cannot_be_used()
+    {
+        $this->populateDatabase();
+
+        Carbon::setTestNow('2027-01-01 02:30:00');
+
+        $response = $this->send(
+            $this->request('GET', '/api')->withCookieParams([
+                'flarum_remember' => 'b',
+            ])
+        );
+
+        Carbon::setTestNow();
+
+        $data = json_decode($response->getBody(), true);
+        $this->assertFalse($data['data']['attributes']['canViewUserList']);
+    }
+
+    /**
+     * @test
+     */
+    public function valid_tokens_can_be_used()
+    {
+        $this->populateDatabase();
+
+        Carbon::setTestNow('2021-01-01 02:30:00');
+
+        $response = $this->send(
+            $this->request('GET', '/api')->withCookieParams([
+                'flarum_remember' => 'b',
+            ])
+        );
+
+        Carbon::setTestNow();
+
+        $data = json_decode($response->getBody(), true);
+        $this->assertTrue($data['data']['attributes']['canViewUserList']);
+    }
+}
diff --git a/tests/integration/api/authentication/WithTokenTest.php b/tests/integration/api/authentication/WithTokenTest.php
index 79d7b0014..2b014ae69 100644
--- a/tests/integration/api/authentication/WithTokenTest.php
+++ b/tests/integration/api/authentication/WithTokenTest.php
@@ -60,7 +60,7 @@ class WithTokenTest extends TestCase
 
         // ...and an access token belonging to this user.
         $token = $data['token'];
-        $this->assertEquals(2, AccessToken::findOrFail($token)->user_id);
+        $this->assertEquals(2, AccessToken::whereToken($token)->firstOrFail()->user_id);
     }
 
     /**
diff --git a/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php b/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php
index 152bcf4a6..6e00da18f 100644
--- a/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php
+++ b/tests/integration/api/csrf_protection/RequireCsrfTokenTest.php
@@ -193,7 +193,7 @@ class RequireCsrfTokenTest extends TestCase
     public function access_token_does_not_need_csrf_token()
     {
         $this->database()->table('access_tokens')->insert(
-            ['token' => 'myaccesstoken', 'user_id' => 1]
+            ['token' => 'myaccesstoken', 'user_id' => 1, 'type' => 'developer']
         );
 
         $response = $this->send(