diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 29e775d..cce3650 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -12,7 +12,6 @@ namespace Coder.Desktop.App;
 public partial class App : Application
 {
     private readonly IServiceProvider _services;
-    private TrayWindow? _trayWindow;
     private readonly bool _handleClosedEvents = true;
 
     public App()
@@ -21,6 +20,11 @@ public App()
         services.AddSingleton<ICredentialManager, CredentialManager>();
         services.AddSingleton<IRpcController, RpcController>();
 
+        // SignInWindow views and view models
+        services.AddTransient<SignInViewModel>();
+        services.AddTransient<SignInWindow>();
+
+        // TrayWindow views and view models
         services.AddTransient<TrayWindowDisconnectedViewModel>();
         services.AddTransient<TrayWindowDisconnectedPage>();
         services.AddTransient<TrayWindowLoginRequiredViewModel>();
@@ -42,14 +46,14 @@ public App()
 
     protected override void OnLaunched(LaunchActivatedEventArgs args)
     {
-        _trayWindow = _services.GetRequiredService<TrayWindow>();
-        _trayWindow.Closed += (sender, args) =>
+        var trayWindow = _services.GetRequiredService<TrayWindow>();
+        trayWindow.Closed += (sender, args) =>
         {
             // TODO: wire up HandleClosedEvents properly
             if (_handleClosedEvents)
             {
                 args.Handled = true;
-                _trayWindow.AppWindow.Hide();
+                trayWindow.AppWindow.Hide();
             }
         };
     }
diff --git a/App/DisplayScale.cs b/App/DisplayScale.cs
new file mode 100644
index 0000000..cd5101c
--- /dev/null
+++ b/App/DisplayScale.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.UI.Xaml;
+using WinRT.Interop;
+
+namespace Coder.Desktop.App;
+
+/// <summary>
+///     A static utility class to house methods related to the visual scale of the display monitor.
+/// </summary>
+public static class DisplayScale
+{
+    public static double WindowScale(Window win)
+    {
+        var hwnd = WindowNative.GetWindowHandle(win);
+        var dpi = NativeApi.GetDpiForWindow(hwnd);
+        if (dpi == 0) return 1; // assume scale of 1
+        return dpi / 96.0; // 96 DPI == 1
+    }
+
+    public class NativeApi
+    {
+        [DllImport("user32.dll")]
+        public static extern int GetDpiForWindow(IntPtr hwnd);
+    }
+}
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index ad2f366..af1dbae 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -67,6 +67,7 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
         try
         {
             var sdkClient = new CoderApiClient(uri);
+            sdkClient.SetSessionToken(apiToken);
             // TODO: we should probably perform a version check here too,
             // rather than letting the service do it on Start
             _ = await sdkClient.GetBuildInfo(ct);
diff --git a/App/ViewModels/SignInViewModel.cs b/App/ViewModels/SignInViewModel.cs
new file mode 100644
index 0000000..3dc162c
--- /dev/null
+++ b/App/ViewModels/SignInViewModel.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml;
+
+namespace Coder.Desktop.App.ViewModels;
+
+/// <summary>
+///     The View Model backing the sign in window and all its associated pages.
+/// </summary>
+public partial class SignInViewModel : ObservableObject
+{
+    private readonly ICredentialManager _credentialManager;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(CoderUrlError))]
+    [NotifyPropertyChangedFor(nameof(GenTokenUrl))]
+    public partial string CoderUrl { get; set; } = string.Empty;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(CoderUrlError))]
+    public partial bool CoderUrlTouched { get; set; } = false;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ApiTokenError))]
+    public partial string ApiToken { get; set; } = string.Empty;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ApiTokenError))]
+    public partial bool ApiTokenTouched { get; set; } = false;
+
+    [ObservableProperty]
+    public partial string? SignInError { get; set; } = null;
+
+    [ObservableProperty]
+    public partial bool SignInLoading { get; set; } = false;
+
+    public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;
+
+    private string? _coderUrlError
+    {
+        get
+        {
+            if (!Uri.TryCreate(CoderUrl, UriKind.Absolute, out var uri))
+                return "Invalid URL";
+            if (uri.Scheme is not "http" and not "https")
+                return "Must be a HTTP or HTTPS URL";
+            if (uri.PathAndQuery != "/")
+                return "Must be a root URL with no path or query";
+            return null;
+        }
+    }
+
+    public string? ApiTokenError => ApiTokenTouched ? _apiTokenError : null;
+
+    private string? _apiTokenError => string.IsNullOrWhiteSpace(ApiToken) ? "Invalid token" : null;
+
+    public Uri GenTokenUrl
+    {
+        get
+        {
+            // In case somehow the URL is invalid, just default to coder.com.
+            // The HyperlinkButton will crash the entire app if the URL is
+            // invalid.
+            try
+            {
+                var baseUri = new Uri(CoderUrl.Trim());
+                var cliAuthUri = new Uri(baseUri, "/cli-auth");
+                return cliAuthUri;
+            }
+            catch
+            {
+                return new Uri("https://coder.com");
+            }
+        }
+    }
+
+    public SignInViewModel(ICredentialManager credentialManager)
+    {
+        _credentialManager = credentialManager;
+    }
+
+    public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
+    {
+        CoderUrlTouched = true;
+    }
+
+    public void ApiToken_FocusLost(object sender, RoutedEventArgs e)
+    {
+        ApiTokenTouched = true;
+    }
+
+    [RelayCommand]
+    public void UrlPage_Next(SignInWindow signInWindow)
+    {
+        CoderUrlTouched = true;
+        if (_coderUrlError != null) return;
+        signInWindow.NavigateToTokenPage();
+    }
+
+    [RelayCommand]
+    public void TokenPage_Back(SignInWindow signInWindow)
+    {
+        ApiToken = "";
+        signInWindow.NavigateToUrlPage();
+    }
+
+    [RelayCommand]
+    public async Task TokenPage_SignIn(SignInWindow signInWindow)
+    {
+        CoderUrlTouched = true;
+        ApiTokenTouched = true;
+        if (_coderUrlError != null || _apiTokenError != null) return;
+
+        try
+        {
+            SignInLoading = true;
+            SignInError = null;
+
+            var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+            await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);
+
+            signInWindow.Close();
+        }
+        catch (Exception e)
+        {
+            SignInError = $"Failed to sign in: {e}";
+        }
+        finally
+        {
+            SignInLoading = false;
+        }
+    }
+}
diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
index c63b8f4..628be72 100644
--- a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
+++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
@@ -1,13 +1,34 @@
-using CommunityToolkit.Mvvm.ComponentModel;
+using System;
+using Coder.Desktop.App.Views;
 using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
 
 namespace Coder.Desktop.App.ViewModels;
 
-public partial class TrayWindowLoginRequiredViewModel : ObservableObject
+public partial class TrayWindowLoginRequiredViewModel
 {
+    private readonly IServiceProvider _services;
+
+    private SignInWindow? _signInWindow;
+
+    public TrayWindowLoginRequiredViewModel(IServiceProvider services)
+    {
+        _services = services;
+    }
+
     [RelayCommand]
     public void Login()
     {
-        // TODO: open the login window
+        // This is safe against concurrent access since it all happens in the
+        // UI thread.
+        if (_signInWindow != null)
+        {
+            _signInWindow.Activate();
+            return;
+        }
+
+        _signInWindow = _services.GetRequiredService<SignInWindow>();
+        _signInWindow.Closed += (_, _) => _signInWindow = null;
+        _signInWindow.Activate();
     }
 }
diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml
new file mode 100644
index 0000000..dde2d5c
--- /dev/null
+++ b/App/Views/Pages/SignInTokenPage.xaml
@@ -0,0 +1,104 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Page
+    x:Class="Coder.Desktop.App.Views.Pages.SignInTokenPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <StackPanel
+        Orientation="Vertical"
+        HorizontalAlignment="Stretch"
+        VerticalAlignment="Top"
+        Padding="20"
+        Spacing="10">
+
+        <TextBlock
+            Text="Coder Desktop"
+            FontSize="24"
+            VerticalAlignment="Center"
+            HorizontalAlignment="Center" />
+
+        <Grid>
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="Auto" />
+                <ColumnDefinition Width="*" />
+            </Grid.ColumnDefinitions>
+            <Grid.RowDefinitions>
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+            </Grid.RowDefinitions>
+
+            <TextBlock
+                Grid.Column="0"
+                Grid.Row="0"
+                Text="Server URL"
+                HorizontalAlignment="Right"
+                Padding="10" />
+
+            <TextBlock
+                Grid.Column="1"
+                Grid.Row="0"
+                HorizontalAlignment="Stretch"
+                VerticalAlignment="Center"
+                Padding="10"
+                Text="{x:Bind ViewModel.CoderUrl, Mode=OneWay}" />
+
+            <TextBlock
+                Grid.Column="0"
+                Grid.Row="2"
+                Text="Session Token"
+                HorizontalAlignment="Right"
+                Padding="10" />
+
+            <TextBox
+                Grid.Column="1"
+                Grid.Row="2"
+                HorizontalAlignment="Stretch"
+                PlaceholderText="Paste your token here"
+                LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
+                Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
+
+            <TextBlock
+                Grid.Column="1"
+                Grid.Row="3"
+                Text="{x:Bind ViewModel.ApiTokenError, Mode=OneWay}"
+                Foreground="Red" />
+
+            <HyperlinkButton
+                Grid.Column="1"
+                Grid.Row="4"
+                Content="Generate a token via the Web UI"
+                NavigateUri="{x:Bind ViewModel.GenTokenUrl, Mode=OneWay}" />
+        </Grid>
+
+        <StackPanel
+            Orientation="Horizontal"
+            HorizontalAlignment="Center"
+            Spacing="10">
+
+            <Button
+                Content="Back" HorizontalAlignment="Right"
+                Command="{x:Bind ViewModel.TokenPage_BackCommand, Mode=OneWay}"
+                CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
+
+            <Button
+                Content="Sign In"
+                HorizontalAlignment="Left"
+                Style="{StaticResource AccentButtonStyle}"
+                Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
+                CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
+        </StackPanel>
+
+        <TextBlock
+            Text="{x:Bind ViewModel.SignInError, Mode=OneWay}"
+            HorizontalAlignment="Center"
+            Foreground="Red" />
+    </StackPanel>
+</Page>
diff --git a/App/Views/Pages/SignInTokenPage.xaml.cs b/App/Views/Pages/SignInTokenPage.xaml.cs
new file mode 100644
index 0000000..1219508
--- /dev/null
+++ b/App/Views/Pages/SignInTokenPage.xaml.cs
@@ -0,0 +1,20 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+/// <summary>
+///     A sign in page to accept the user's Coder token.
+/// </summary>
+public sealed partial class SignInTokenPage : Page
+{
+    public readonly SignInViewModel ViewModel;
+    public readonly SignInWindow SignInWindow;
+
+    public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel)
+    {
+        InitializeComponent();
+        ViewModel = viewModel;
+        SignInWindow = parent;
+    }
+}
diff --git a/App/Views/Pages/SignInUrlPage.xaml b/App/Views/Pages/SignInUrlPage.xaml
new file mode 100644
index 0000000..1c12b03
--- /dev/null
+++ b/App/Views/Pages/SignInUrlPage.xaml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Page
+    x:Class="Coder.Desktop.App.Views.Pages.SignInUrlPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <StackPanel
+        Orientation="Vertical"
+        HorizontalAlignment="Stretch"
+        VerticalAlignment="Top"
+        Padding="20"
+        Spacing="10">
+
+        <TextBlock
+            Text="Coder Desktop"
+            FontSize="24"
+            VerticalAlignment="Center"
+            HorizontalAlignment="Center" />
+
+        <Grid>
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="Auto" />
+                <ColumnDefinition Width="*" />
+            </Grid.ColumnDefinitions>
+            <Grid.RowDefinitions>
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+            </Grid.RowDefinitions>
+
+            <TextBlock
+                Grid.Column="0"
+                Grid.Row="0"
+                Text="Server URL"
+                HorizontalAlignment="Right"
+                Padding="10 " />
+
+            <TextBox
+                Grid.Column="1"
+                Grid.Row="0"
+                HorizontalAlignment="Stretch"
+                PlaceholderText="https://coder.example.com"
+                LostFocus="{x:Bind ViewModel.CoderUrl_FocusLost, Mode=OneWay}"
+                Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay}" />
+
+            <TextBlock
+                Grid.Column="1"
+                Grid.Row="1"
+                Text="{x:Bind ViewModel.CoderUrlError, Mode=OneWay}"
+                Foreground="Red" />
+        </Grid>
+
+        <Button
+            Content="Next"
+            HorizontalAlignment="Center"
+            Command="{x:Bind ViewModel.UrlPage_NextCommand, Mode=OneWay}"
+            CommandParameter="{x:Bind SignInWindow, Mode=OneWay}"
+            Style="{StaticResource AccentButtonStyle}" />
+    </StackPanel>
+</Page>
diff --git a/App/Views/Pages/SignInUrlPage.xaml.cs b/App/Views/Pages/SignInUrlPage.xaml.cs
new file mode 100644
index 0000000..175a8c2
--- /dev/null
+++ b/App/Views/Pages/SignInUrlPage.xaml.cs
@@ -0,0 +1,20 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+/// <summary>
+///     A login page to enter the Coder Server URL
+/// </summary>
+public sealed partial class SignInUrlPage : Page
+{
+    public readonly SignInViewModel ViewModel;
+    public readonly SignInWindow SignInWindow;
+
+    public SignInUrlPage(SignInWindow parent, SignInViewModel viewModel)
+    {
+        InitializeComponent();
+        ViewModel = viewModel;
+        SignInWindow = parent;
+    }
+}
diff --git a/App/Views/SignInWindow.xaml b/App/Views/SignInWindow.xaml
new file mode 100644
index 0000000..299c0c1
--- /dev/null
+++ b/App/Views/SignInWindow.xaml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Window
+    x:Class="Coder.Desktop.App.Views.SignInWindow"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Title="Sign in to Coder">
+
+    <Window.SystemBackdrop>
+        <DesktopAcrylicBackdrop />
+    </Window.SystemBackdrop>
+
+    <Frame x:Name="RootFrame" />
+</Window>
diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs
new file mode 100644
index 0000000..5549e7c
--- /dev/null
+++ b/App/Views/SignInWindow.xaml.cs
@@ -0,0 +1,46 @@
+using Windows.Graphics;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Xaml;
+
+namespace Coder.Desktop.App.Views;
+
+/// <summary>
+///     The dialog window to allow the user to sign into their Coder server.
+/// </summary>
+public sealed partial class SignInWindow : Window
+{
+    private const double WIDTH = 600.0;
+    private const double HEIGHT = 300.0;
+
+    private readonly SignInUrlPage _signInUrlPage;
+    private readonly SignInTokenPage _signInTokenPage;
+
+    public SignInWindow(SignInViewModel viewModel)
+    {
+        InitializeComponent();
+        _signInUrlPage = new SignInUrlPage(this, viewModel);
+        _signInTokenPage = new SignInTokenPage(this, viewModel);
+
+        NavigateToUrlPage();
+        ResizeWindow();
+    }
+
+    public void NavigateToTokenPage()
+    {
+        RootFrame.Content = _signInTokenPage;
+    }
+
+    public void NavigateToUrlPage()
+    {
+        RootFrame.Content = _signInUrlPage;
+    }
+
+    private void ResizeWindow()
+    {
+        var scale = DisplayScale.WindowScale(this);
+        var height = (int)(HEIGHT * scale);
+        var width = (int)(WIDTH * scale);
+        AppWindow.Resize(new SizeInt32(width, height));
+    }
+}