diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
new file mode 100644
index 0000000..fdd10b7
--- /dev/null
+++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
@@ -0,0 +1,25 @@
+using Foxnouns.Backend.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Foxnouns.Backend.Controllers.Authentication;
+
+[Route("/api/internal/auth/fediverse")]
+public class FediverseAuthController(FediverseAuthService fediverseAuthService) : ApiControllerBase
+{
+    [HttpGet]
+    [ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
+    public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
+    {
+        var url = await fediverseAuthService.GenerateAuthUrlAsync(instance);
+        return Ok(new FediverseUrlResponse(url));
+    }
+
+    public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
+    {
+        throw new NotImplementedException();
+    }
+
+    public record CallbackRequest(string Instance, string Code);
+
+    private record FediverseUrlResponse(string Url);
+}
diff --git a/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs b/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs
new file mode 100644
index 0000000..37023f0
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+    /// <inheritdoc />
+    [DbContext(typeof(DatabaseContext))]
+    [Migration("20241006125003_AddFediverseAccessTokens")]
+    public partial class AddFediverseAccessTokens : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AddColumn<string>(
+                name: "access_token",
+                table: "fediverse_applications",
+                type: "text",
+                nullable: true
+            );
+
+            migrationBuilder.AddColumn<Instant>(
+                name: "token_valid_until",
+                table: "fediverse_applications",
+                type: "timestamp with time zone",
+                nullable: true
+            );
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
+
+            migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
+        }
+    }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
index e1e05c2..97316ac 100644
--- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
+++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
@@ -107,6 +107,10 @@ namespace Foxnouns.Backend.Database.Migrations
                         .HasColumnType("bigint")
                         .HasColumnName("id");
 
+                    b.Property<string>("AccessToken")
+                        .HasColumnType("text")
+                        .HasColumnName("access_token");
+
                     b.Property<string>("ClientId")
                         .IsRequired()
                         .HasColumnType("text")
@@ -126,6 +130,10 @@ namespace Foxnouns.Backend.Database.Migrations
                         .HasColumnType("integer")
                         .HasColumnName("instance_type");
 
+                    b.Property<Instant?>("TokenValidUntil")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("token_valid_until");
+
                     b.HasKey("Id")
                         .HasName("pk_fediverse_applications");
 
diff --git a/Foxnouns.Backend/Database/Models/FediverseApplication.cs b/Foxnouns.Backend/Database/Models/FediverseApplication.cs
index 6dc813d..fa7b6a6 100644
--- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs
+++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs
@@ -1,3 +1,5 @@
+using NodaTime;
+
 namespace Foxnouns.Backend.Database.Models;
 
 public class FediverseApplication : BaseModel
@@ -6,6 +8,10 @@ public class FediverseApplication : BaseModel
     public required string ClientId { get; set; }
     public required string ClientSecret { get; set; }
     public required FediverseInstanceType InstanceType { get; set; }
+
+    // These are for ensuring the application is still valid.
+    public string? AccessToken { get; set; }
+    public Instant? TokenValidUntil { get; set; }
 }
 
 public enum FediverseInstanceType
diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs
index fdd0b5d..1b92a7e 100644
--- a/Foxnouns.Backend/ExpectedError.cs
+++ b/Foxnouns.Backend/ExpectedError.cs
@@ -14,6 +14,15 @@ public class FoxnounsError(string message, Exception? inner = null) : Exception(
 
     public class UnknownEntityError(Type entityType, Exception? inner = null)
         : DatabaseError($"Entity of type {entityType.Name} not found", inner);
+
+    public class RemoteAuthError(string message, string? errorBody = null, Exception? inner = null)
+        : FoxnounsError(message, inner)
+    {
+        public string? ErrorBody => errorBody;
+
+        public override string ToString() =>
+            $"{Message}: {ErrorBody} {(Inner != null ? $"({Inner})" : "")}";
+    }
 }
 
 public class ApiError(
diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
index 3e6926c..b2e519d 100644
--- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
+++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
@@ -101,6 +101,7 @@ public static class WebApplicationExtensions
                     .AddScoped<AuthService>()
                     .AddScoped<KeyCacheService>()
                     .AddScoped<RemoteAuthService>()
+                    .AddScoped<FediverseAuthService>()
                     .AddScoped<ObjectStorageService>()
                     // Background services
                     .AddHostedService<PeriodicTasksService>()
diff --git a/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs
new file mode 100644
index 0000000..c8d9dd5
--- /dev/null
+++ b/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs
@@ -0,0 +1,246 @@
+using System.Net;
+using System.Web;
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Minio.DataModel.ILM;
+using Duration = NodaTime.Duration;
+using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
+
+namespace Foxnouns.Backend.Services;
+
+public partial class FediverseAuthService
+{
+    private string MastodonRedirectUri(string instance) =>
+        $"{_config.BaseUrl}/auth/login/mastodon/{instance}";
+
+    private async Task<FediverseApplication> CreateMastodonApplicationAsync(
+        string instance,
+        Snowflake? existingAppId = null
+    )
+    {
+        var resp = await _client.PostAsync(
+            $"https://{instance}/api/v1/apps",
+            new FormUrlEncodedContent(
+                new Dictionary<string, string>
+                {
+                    { "client_name", $"pronouns.cc (+{_config.BaseUrl})" },
+                    { "redirect_uris", MastodonRedirectUri(instance) },
+                    { "scope", "read:accounts" },
+                    { "website", _config.BaseUrl },
+                }
+            )
+        );
+        resp.EnsureSuccessStatusCode();
+
+        var mastodonApp = await resp.Content.ReadFromJsonAsync<PartialMastodonApplication>();
+        if (mastodonApp == null)
+            throw new FoxnounsError(
+                $"Application created on Mastodon-compatible instance {instance} was null"
+            );
+
+        var token = await GetMastodonAppTokenAsync(
+            instance,
+            mastodonApp.ClientId,
+            mastodonApp.ClientSecret
+        );
+
+        FediverseApplication app;
+
+        if (existingAppId == null)
+        {
+            app = new FediverseApplication
+            {
+                Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(),
+                ClientId = mastodonApp.ClientId,
+                ClientSecret = mastodonApp.ClientSecret,
+                Domain = instance,
+                InstanceType = FediverseInstanceType.MastodonApi,
+                AccessToken = token,
+                TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60),
+            };
+
+            _db.Add(app);
+        }
+        else
+        {
+            app =
+                await _db.FediverseApplications.FindAsync(existingAppId)
+                ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
+
+            app.ClientId = mastodonApp.ClientId;
+            app.ClientSecret = mastodonApp.ClientSecret;
+            app.InstanceType = FediverseInstanceType.MastodonApi;
+            app.AccessToken = null;
+            app.TokenValidUntil = null;
+        }
+
+        await _db.SaveChangesAsync();
+
+        return app;
+    }
+
+    private async Task<FediverseUser> GetMastodonUserAsync(FediverseApplication app, string code)
+    {
+        var tokenResp = await _client.PostAsync(
+            MastodonTokenUri(app.Domain),
+            new FormUrlEncodedContent(
+                new Dictionary<string, string>
+                {
+                    { "grant_type", "authorization_code" },
+                    { "code", code },
+                    { "scope", "read:accounts" },
+                    { "client_id", app.ClientId },
+                    { "client_secret", app.ClientSecret },
+                    { "redirect_uri", MastodonRedirectUri(app.Domain) },
+                }
+            )
+        );
+        if (tokenResp.StatusCode == HttpStatusCode.Unauthorized)
+        {
+            throw new FoxnounsError($"Application for instance {app.Domain} was invalid");
+        }
+
+        tokenResp.EnsureSuccessStatusCode();
+        var token = (
+            await tokenResp.Content.ReadFromJsonAsync<MastodonTokenResponse>()
+        )?.AccessToken;
+        if (token == null)
+        {
+            throw new FoxnounsError($"Token response from instance {app.Domain} was invalid");
+        }
+
+        var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain));
+        req.Headers.Add("Authorization", $"Bearer {token}");
+
+        var currentUserResp = await _client.SendAsync(req);
+        currentUserResp.EnsureSuccessStatusCode();
+        var user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
+        if (user == null)
+        {
+            throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
+        }
+
+        return user;
+    }
+
+    private record MastodonTokenResponse([property: J("access_token")] string AccessToken);
+
+    // TODO: Mastodon's OAuth documentation doesn't specify a "state" parameter. that feels... wrong
+    // https://docs.joinmastodon.org/methods/oauth/
+    private async Task<string> GenerateMastodonAuthUrlAsync(FediverseApplication app)
+    {
+        try
+        {
+            await ValidateMastodonAppAsync(app);
+        }
+        catch (FoxnounsError.RemoteAuthError e)
+        {
+            _logger.Error(
+                e,
+                "Error validating app token for {AppId} on {Instance}",
+                app.Id,
+                app.Domain
+            );
+
+            app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
+        }
+
+        return $"https://{app.Domain}/oauth/authorize?response_type=code"
+            + $"&client_id={app.ClientId}"
+            + $"&scope={HttpUtility.UrlEncode("read:accounts")}"
+            + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}";
+    }
+
+    private async Task ValidateMastodonAppAsync(FediverseApplication app)
+    {
+        // If we don't have an access token stored, or it's too old, get one
+        // When doing this we don't need to fetch the application info
+        if (app.AccessToken == null || app.TokenValidUntil < _clock.GetCurrentInstant())
+        {
+            _logger.Debug(
+                "Application {AppId} on instance {Instance} has no valid token, fetching it",
+                app.Id,
+                app.Domain
+            );
+
+            app.AccessToken = await GetMastodonAppTokenAsync(
+                app.Domain,
+                app.ClientId,
+                app.ClientSecret
+            );
+            app.TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60);
+
+            _db.Update(app);
+            await _db.SaveChangesAsync();
+            return;
+        }
+
+        _logger.Debug(
+            "Checking whether application {AppId} on instance {Instance} is still valid",
+            app.Id,
+            app.Domain
+        );
+
+        var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentAppUri(app.Domain));
+        req.Headers.Add("Authorization", $"Bearer {app.AccessToken}");
+
+        var resp = await _client.SendAsync(req);
+        if (!resp.IsSuccessStatusCode)
+        {
+            var error = await resp.Content.ReadAsStringAsync();
+            throw new FoxnounsError.RemoteAuthError(
+                "Verifying app credentials returned an error",
+                error
+            );
+        }
+    }
+
+    private async Task<string> GetMastodonAppTokenAsync(
+        string instance,
+        string clientId,
+        string clientSecret
+    )
+    {
+        var resp = await _client.PostAsync(
+            MastodonTokenUri(instance),
+            new FormUrlEncodedContent(
+                new Dictionary<string, string>
+                {
+                    { "grant_type", "client_credentials" },
+                    { "client_id", clientId },
+                    { "client_secret", clientSecret },
+                }
+            )
+        );
+        if (!resp.IsSuccessStatusCode)
+        {
+            var error = await resp.Content.ReadAsStringAsync();
+            throw new FoxnounsError.RemoteAuthError(
+                "Requesting app token returned an error",
+                error
+            );
+        }
+
+        var token = (await resp.Content.ReadFromJsonAsync<MastodonTokenResponse>())?.AccessToken;
+        if (token == null)
+        {
+            throw new FoxnounsError($"Token response from instance {instance} was invalid");
+        }
+
+        return token;
+    }
+
+    private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token";
+
+    private static string MastodonCurrentUserUri(string instance) =>
+        $"https://{instance}/api/v1/accounts/verify_credentials";
+
+    private static string MastodonCurrentAppUri(string instance) =>
+        $"https://{instance}/api/v1/apps/verify_credentials";
+
+    private record PartialMastodonApplication(
+        [property: J("name")] string Name,
+        [property: J("client_id")] string ClientId,
+        [property: J("client_secret")] string ClientSecret
+    );
+}
diff --git a/Foxnouns.Backend/Services/FediverseAuthService.cs b/Foxnouns.Backend/Services/FediverseAuthService.cs
new file mode 100644
index 0000000..ff39e88
--- /dev/null
+++ b/Foxnouns.Backend/Services/FediverseAuthService.cs
@@ -0,0 +1,162 @@
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Microsoft.EntityFrameworkCore;
+using NodaTime;
+using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
+
+namespace Foxnouns.Backend.Services;
+
+public partial class FediverseAuthService
+{
+    private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
+
+    private readonly ILogger _logger;
+    private readonly HttpClient _client;
+    private readonly DatabaseContext _db;
+    private readonly Config _config;
+    private readonly ISnowflakeGenerator _snowflakeGenerator;
+    private readonly IClock _clock;
+
+    public FediverseAuthService(
+        ILogger logger,
+        Config config,
+        DatabaseContext db,
+        ISnowflakeGenerator snowflakeGenerator,
+        IClock clock
+    )
+    {
+        _config = config;
+        _db = db;
+        _snowflakeGenerator = snowflakeGenerator;
+        _clock = clock;
+        _logger = logger.ForContext<FediverseAuthService>();
+        _client = new HttpClient();
+        _client.DefaultRequestHeaders.Remove("User-Agent");
+        _client.DefaultRequestHeaders.Remove("Accept");
+        _client.DefaultRequestHeaders.Add("User-Agent", $"pronouns.cc/{BuildInfo.Version}");
+        _client.DefaultRequestHeaders.Add("Accept", "application/json");
+    }
+
+    public async Task<string> GenerateAuthUrlAsync(string instance)
+    {
+        var app = await GetApplicationAsync(instance);
+        return await GenerateAuthUrlAsync(app);
+    }
+
+    public async Task<FediverseUser> GetRemoteFediverseUserAsync(string instance, string code)
+    {
+        var app = await GetApplicationAsync(instance);
+        return await GetRemoteUserAsync(app, code);
+    }
+
+    // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
+    // and having both mastodon and misskey use "username" in the self user response
+    public record FediverseUser(
+        [property: J("id")] string Id,
+        [property: J("username")] string Username
+    );
+
+    private async Task<FediverseApplication> GetApplicationAsync(string instance)
+    {
+        var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
+        if (app != null)
+            return app;
+
+        _logger.Debug("No application for fediverse instance {Instance}, creating it", instance);
+
+        var softwareName = await GetSoftwareNameAsync(instance);
+
+        if (IsMastodonCompatible(softwareName))
+        {
+            return await CreateMastodonApplicationAsync(instance);
+        }
+
+        throw new NotImplementedException();
+    }
+
+    private async Task<string> GetSoftwareNameAsync(string instance)
+    {
+        _logger.Debug("Requesting software name for fediverse instance {Instance}", instance);
+
+        var wellKnownResp = await _client.GetAsync(
+            new Uri($"https://{instance}/.well-known/nodeinfo")
+        );
+        wellKnownResp.EnsureSuccessStatusCode();
+
+        var wellKnown = await wellKnownResp.Content.ReadFromJsonAsync<WellKnownResponse>();
+        var nodeInfoUrl = wellKnown?.Links.FirstOrDefault(l => l.Rel == NodeInfoRel)?.Href;
+        if (nodeInfoUrl == null)
+        {
+            throw new FoxnounsError(
+                $".well-known/nodeinfo response for instance {instance} was invalid, no nodeinfo link"
+            );
+        }
+
+        var nodeInfoResp = await _client.GetAsync(nodeInfoUrl);
+        nodeInfoResp.EnsureSuccessStatusCode();
+
+        var nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>();
+        return nodeInfo?.Software.Name
+            ?? throw new FoxnounsError(
+                $"Nodeinfo response for instance {instance} was invalid, no software name"
+            );
+    }
+
+    private async Task<string> GenerateAuthUrlAsync(FediverseApplication app) =>
+        app.InstanceType switch
+        {
+            FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app),
+            FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
+            _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
+        };
+
+    private async Task<FediverseUser> GetRemoteUserAsync(FediverseApplication app, string code) =>
+        app.InstanceType switch
+        {
+            FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),
+            FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
+            _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
+        };
+
+    private static readonly string[] MastodonSoftwareNames =
+    [
+        "mastodon",
+        "hometown",
+        "akkoma",
+        "pleroma",
+        "iceshrimp.net",
+        "iceshrimp",
+        "gotosocial",
+        "pixelfed",
+    ];
+
+    private static readonly string[] MisskeySoftwareNames =
+    [
+        "misskey",
+        "foundkey",
+        "calckey",
+        "firefish",
+        "sharkey",
+    ];
+
+    private static bool IsMastodonCompatible(string softwareName) =>
+        MastodonSoftwareNames.Any(n =>
+            string.Equals(softwareName, n, StringComparison.InvariantCultureIgnoreCase)
+        );
+
+    private static bool IsMisskeyCompatible(string softwareName) =>
+        MisskeySoftwareNames.Any(n =>
+            string.Equals(softwareName, n, StringComparison.InvariantCultureIgnoreCase)
+        );
+
+    private record WellKnownResponse([property: J("links")] WellKnownLink[] Links);
+
+    private record WellKnownLink(
+        [property: J("rel")] string Rel,
+        [property: J("href")] string Href
+    );
+
+    private record PartialNodeInfo([property: J("software")] NodeInfoSoftware Software);
+
+    private record NodeInfoSoftware([property: J("name")] string Name);
+}