diff --git a/Gemfile b/Gemfile
index b64a1dbe91..9e5955e0b8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -62,6 +62,7 @@ gem 'inline_svg'
 gem 'irb', '~> 1.8'
 gem 'kaminari', '~> 1.2'
 gem 'link_header', '~> 0.0'
+gem 'linzer', '~> 0.6.1'
 gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
 gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
 gem 'mutex_m'
diff --git a/Gemfile.lock b/Gemfile.lock
index 04d5aac6cd..9493ab6778 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -203,6 +203,7 @@ GEM
       railties (>= 5)
     dotenv (3.1.7)
     drb (2.2.1)
+    ed25519 (1.3.0)
     elasticsearch (7.17.11)
       elasticsearch-api (= 7.17.11)
       elasticsearch-transport (= 7.17.11)
@@ -395,6 +396,12 @@ GEM
       rexml
     link_header (0.0.8)
     lint_roller (1.1.0)
+    linzer (0.6.1)
+      ed25519 (~> 1.3, >= 1.3.0)
+      openssl (~> 3.0, >= 3.0.0)
+      rack (>= 2.2, < 4.0)
+      starry (~> 0.2)
+      uri (~> 1.0, >= 1.0.2)
     llhttp-ffi (0.5.1)
       ffi-compiler (~> 1.0)
       rake (~> 13.0)
@@ -827,6 +834,8 @@ GEM
     simplecov-lcov (0.8.0)
     simplecov_json_formatter (0.1.4)
     stackprof (0.2.27)
+    starry (0.2.0)
+      base64
     stoplight (4.1.1)
       redlock (~> 1.0)
     stringio (3.1.5)
@@ -978,6 +987,7 @@ DEPENDENCIES
   letter_opener (~> 1.8)
   letter_opener_web (~> 3.0)
   link_header (~> 0.0)
+  linzer (~> 0.6.1)
   lograge (~> 0.12)
   mail (~> 2.8)
   mario-redis-lock (~> 1.2)
diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb
new file mode 100644
index 0000000000..28aba5e489
--- /dev/null
+++ b/app/controllers/admin/fasp/debug/callbacks_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
+  def index
+    authorize [:admin, :fasp, :provider], :update?
+
+    @callbacks = Fasp::DebugCallback
+                 .includes(:fasp_provider)
+                 .order(created_at: :desc)
+  end
+
+  def destroy
+    authorize [:admin, :fasp, :provider], :update?
+
+    callback = Fasp::DebugCallback.find(params[:id])
+    callback.destroy
+
+    redirect_to admin_fasp_debug_callbacks_path
+  end
+end
diff --git a/app/controllers/admin/fasp/debug_calls_controller.rb b/app/controllers/admin/fasp/debug_calls_controller.rb
new file mode 100644
index 0000000000..1e1b6dbf3c
--- /dev/null
+++ b/app/controllers/admin/fasp/debug_calls_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Admin::Fasp::DebugCallsController < Admin::BaseController
+  before_action :set_provider
+
+  def create
+    authorize [:admin, @provider], :update?
+
+    @provider.perform_debug_call
+
+    redirect_to admin_fasp_providers_path
+  end
+
+  private
+
+  def set_provider
+    @provider = Fasp::Provider.find(params[:provider_id])
+  end
+end
diff --git a/app/controllers/admin/fasp/providers_controller.rb b/app/controllers/admin/fasp/providers_controller.rb
new file mode 100644
index 0000000000..ac5226d438
--- /dev/null
+++ b/app/controllers/admin/fasp/providers_controller.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class Admin::Fasp::ProvidersController < Admin::BaseController
+  before_action :set_provider, only: [:show, :edit, :update, :destroy]
+
+  def index
+    authorize [:admin, :fasp, :provider], :index?
+
+    @providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc)
+  end
+
+  def show
+    authorize [:admin, @provider], :show?
+  end
+
+  def edit
+    authorize [:admin, @provider], :update?
+  end
+
+  def update
+    authorize [:admin, @provider], :update?
+
+    if @provider.update(provider_params)
+      redirect_to admin_fasp_providers_path
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    authorize [:admin, @provider], :destroy?
+
+    @provider.destroy
+
+    redirect_to admin_fasp_providers_path
+  end
+
+  private
+
+  def provider_params
+    params.require(:provider).permit(enabled_capabilities: {})
+  end
+
+  def set_provider
+    @provider = Fasp::Provider.find(params[:id])
+  end
+end
diff --git a/app/controllers/admin/fasp/registrations_controller.rb b/app/controllers/admin/fasp/registrations_controller.rb
new file mode 100644
index 0000000000..52c46c2eb6
--- /dev/null
+++ b/app/controllers/admin/fasp/registrations_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Admin::Fasp::RegistrationsController < Admin::BaseController
+  before_action :set_provider
+
+  def new
+    authorize [:admin, @provider], :create?
+  end
+
+  def create
+    authorize [:admin, @provider], :create?
+
+    @provider.update_info!(confirm: true)
+
+    redirect_to edit_admin_fasp_provider_path(@provider)
+  end
+
+  private
+
+  def set_provider
+    @provider = Fasp::Provider.find(params[:provider_id])
+  end
+end
diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb
new file mode 100644
index 0000000000..a189a5e226
--- /dev/null
+++ b/app/controllers/api/fasp/base_controller.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+class Api::Fasp::BaseController < ApplicationController
+  class Error < ::StandardError; end
+
+  DIGEST_PATTERN = /sha-256=:(.*?):/
+  KEYID_PATTERN = /keyid="(.*?)"/
+
+  attr_reader :current_provider
+
+  skip_forgery_protection
+
+  before_action :require_authentication
+  after_action :sign_response
+
+  private
+
+  def require_authentication
+    validate_content_digest!
+    validate_signature!
+  rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e
+    logger.debug("FASP Authentication error: #{e}")
+    authentication_error
+  end
+
+  def authentication_error
+    respond_to do |format|
+      format.json { head 401 }
+    end
+  end
+
+  def validate_content_digest!
+    content_digest_header = request.headers['content-digest']
+    raise Error, 'content-digest missing' if content_digest_header.blank?
+
+    digest_received = content_digest_header.match(DIGEST_PATTERN)[1]
+
+    digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '')
+
+    raise Error, 'content-digest does not match' if digest_received != digest_computed
+  end
+
+  def validate_signature!
+    signature_input = request.headers['signature-input']&.encode('UTF-8')
+    raise Error, 'signature-input is missing' if signature_input.blank?
+
+    keyid = signature_input.match(KEYID_PATTERN)[1]
+    provider = Fasp::Provider.find(keyid)
+    linzer_request = Linzer.new_request(
+      request.method,
+      request.original_url,
+      {},
+      {
+        'content-digest' => request.headers['content-digest'],
+        'signature-input' => signature_input,
+        'signature' => request.headers['signature'],
+      }
+    )
+    message = Linzer::Message.new(linzer_request)
+    key = Linzer.new_ed25519_public_key(provider.provider_public_key_raw, keyid)
+    signature = Linzer::Signature.build(message.headers)
+    Linzer.verify(key, message, signature)
+    @current_provider = provider
+  end
+
+  def sign_response
+    response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:"
+
+    linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] })
+    message = Linzer::Message.new(linzer_response)
+    key = Linzer.new_ed25519_key(current_provider.server_private_key.raw_private_key)
+    signature = Linzer.sign(key, message, %w(@status content-digest))
+
+    response.headers.merge!(signature.to_h)
+  end
+end
diff --git a/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb
new file mode 100644
index 0000000000..794e53f095
--- /dev/null
+++ b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController
+  def create
+    Fasp::DebugCallback.create(
+      fasp_provider: current_provider,
+      ip: request.remote_ip,
+      request_body: request.raw_post
+    )
+
+    respond_to do |format|
+      format.json { head 201 }
+    end
+  end
+end
diff --git a/app/controllers/api/fasp/registrations_controller.rb b/app/controllers/api/fasp/registrations_controller.rb
new file mode 100644
index 0000000000..40e711bd0c
--- /dev/null
+++ b/app/controllers/api/fasp/registrations_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Api::Fasp::RegistrationsController < Api::Fasp::BaseController
+  skip_before_action :require_authentication
+
+  def create
+    @current_provider = Fasp::Provider.create!(
+      name: params[:name],
+      base_url: params[:baseUrl],
+      remote_identifier: params[:serverId],
+      provider_public_key_base64: params[:publicKey]
+    )
+
+    render json: registration_confirmation
+  end
+
+  private
+
+  def registration_confirmation
+    {
+      faspId: current_provider.id.to_s,
+      publicKey: current_provider.server_public_key_base64,
+      registrationCompletionUri: admin_fasp_provider_url(current_provider),
+    }
+  end
+end
diff --git a/app/lib/fasp/request.rb b/app/lib/fasp/request.rb
new file mode 100644
index 0000000000..acb1d2ff25
--- /dev/null
+++ b/app/lib/fasp/request.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class Fasp::Request
+  def initialize(provider)
+    @provider = provider
+  end
+
+  def get(path)
+    url = @provider.url(path)
+    response = HTTP.headers(headers('GET', url)).get(url)
+    validate!(response)
+
+    response.parse
+  end
+
+  def post(path, body: nil)
+    url = @provider.url(path)
+    body = body.to_json
+    response = HTTP.headers(headers('POST', url, body)).post(url, body:)
+
+    response.parse if response.body.present?
+  end
+
+  private
+
+  def headers(verb, url, body = '')
+    result = {
+      'accept' => 'application/json',
+      'content-digest' => content_digest(body),
+    }
+    result.merge(signature_headers(verb, url, result))
+  end
+
+  def content_digest(body)
+    "sha-256=:#{OpenSSL::Digest.base64digest('sha256', body || '')}:"
+  end
+
+  def signature_headers(verb, url, headers)
+    linzer_request = Linzer.new_request(verb, url, {}, headers)
+    message = Linzer::Message.new(linzer_request)
+    key = Linzer.new_ed25519_key(@provider.server_private_key.raw_private_key, @provider.remote_identifier)
+    signature = Linzer.sign(key, message, %w(@method @target-uri content-digest))
+    Linzer::Signer.send(:populate_parameters, key, {})
+
+    signature.to_h
+  end
+
+  def validate!(response)
+    content_digest_header = response.headers['content-digest']
+    raise 'content-digest missing' if content_digest_header.blank?
+    raise 'content-digest does not match' if content_digest_header != content_digest(response.body)
+
+    signature_input = response.headers['signature-input'].encode('UTF-8')
+    raise 'signature-input is missing' if signature_input.blank?
+
+    linzer_response = Linzer.new_response(
+      response.body,
+      response.status,
+      {
+        'content-digest' => content_digest_header,
+        'signature-input' => signature_input,
+        'signature' => response.headers['signature'],
+      }
+    )
+    message = Linzer::Message.new(linzer_response)
+    key = Linzer.new_ed25519_public_key(@provider.provider_public_key_raw)
+    signature = Linzer::Signature.build(message.headers)
+    Linzer.verify(key, message, signature)
+  end
+end
diff --git a/app/models/concerns/fasp/provider/debug_concern.rb b/app/models/concerns/fasp/provider/debug_concern.rb
new file mode 100644
index 0000000000..eee046a17f
--- /dev/null
+++ b/app/models/concerns/fasp/provider/debug_concern.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Fasp::Provider::DebugConcern
+  extend ActiveSupport::Concern
+
+  def perform_debug_call
+    Fasp::Request.new(self)
+                 .post('/debug/v0/callback/logs', body: { hello: 'world' })
+  end
+end
diff --git a/app/models/fasp.rb b/app/models/fasp.rb
new file mode 100644
index 0000000000..cb33937715
--- /dev/null
+++ b/app/models/fasp.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Fasp
+  def self.table_name_prefix
+    'fasp_'
+  end
+end
diff --git a/app/models/fasp/debug_callback.rb b/app/models/fasp/debug_callback.rb
new file mode 100644
index 0000000000..0a9897ffae
--- /dev/null
+++ b/app/models/fasp/debug_callback.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: fasp_debug_callbacks
+#
+#  id               :bigint(8)        not null, primary key
+#  ip               :string
+#  request_body     :text
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#  fasp_provider_id :bigint(8)        not null
+#
+class Fasp::DebugCallback < ApplicationRecord
+  belongs_to :fasp_provider, class_name: 'Fasp::Provider'
+end
diff --git a/app/models/fasp/provider.rb b/app/models/fasp/provider.rb
new file mode 100644
index 0000000000..a3dc58de49
--- /dev/null
+++ b/app/models/fasp/provider.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: fasp_providers
+#
+#  id                      :bigint(8)        not null, primary key
+#  base_url                :string           not null
+#  capabilities            :jsonb
+#  confirmed               :boolean          default(FALSE), not null
+#  contact_email           :string
+#  fediverse_account       :string
+#  name                    :string           not null
+#  privacy_policy          :jsonb
+#  provider_public_key_pem :string           not null
+#  remote_identifier       :string           not null
+#  server_private_key_pem  :string           not null
+#  sign_in_url             :string
+#  created_at              :datetime         not null
+#  updated_at              :datetime         not null
+#
+class Fasp::Provider < ApplicationRecord
+  include DebugConcern
+
+  has_many :fasp_debug_callbacks, inverse_of: :fasp_provider, class_name: 'Fasp::DebugCallback', dependent: :delete_all
+
+  before_create :create_keypair
+
+  def enabled_capabilities=(hash)
+    capabilities.each do |capability|
+      capability['enabled'] = hash[capability['id']] == '1'
+    end
+    save!
+  end
+
+  def capability?(capability_name, only_enabled: true)
+    return false unless confirmed?
+
+    capabilities.present? && capabilities.any? do |capability|
+      capability['id'] == capability_name &&
+        (only_enabled ? capability['enabled'] : true)
+    end
+  end
+
+  def server_private_key
+    @server_private_key ||= OpenSSL::PKey.read(server_private_key_pem)
+  end
+
+  def server_public_key_base64
+    Base64.strict_encode64(server_private_key.raw_public_key)
+  end
+
+  def provider_public_key_base64=(string)
+    self.provider_public_key_pem =
+      OpenSSL::PKey.new_raw_public_key(
+        'ed25519',
+        Base64.strict_decode64(string)
+      ).public_to_pem
+  end
+
+  def provider_public_key
+    @provider_public_key ||= OpenSSL::PKey.read(provider_public_key_pem)
+  end
+
+  def provider_public_key_raw
+    provider_public_key.raw_public_key
+  end
+
+  def provider_public_key_fingerprint
+    OpenSSL::Digest.base64digest('sha256', provider_public_key_raw)
+  end
+
+  def url(path)
+    base = base_url
+    base = base.chomp('/') if path.start_with?('/')
+    "#{base}#{path}"
+  end
+
+  def update_info!(confirm: false)
+    self.confirmed = true if confirm
+    provider_info = Fasp::Request.new(self).get('/provider_info')
+    assign_attributes(
+      privacy_policy: provider_info['privacyPolicy'],
+      capabilities: provider_info['capabilities'],
+      sign_in_url: provider_info['signInUrl'],
+      contact_email: provider_info['contactEmail'],
+      fediverse_account: provider_info['fediverseAccount']
+    )
+    save!
+  end
+
+  private
+
+  def create_keypair
+    self.server_private_key_pem =
+      OpenSSL::PKey.generate_key('ed25519').private_to_pem
+  end
+end
diff --git a/app/policies/admin/fasp/provider_policy.rb b/app/policies/admin/fasp/provider_policy.rb
new file mode 100644
index 0000000000..a8088fd37d
--- /dev/null
+++ b/app/policies/admin/fasp/provider_policy.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Admin::Fasp::ProviderPolicy < ApplicationPolicy
+  def index?
+    role.can?(:manage_federation)
+  end
+
+  def show?
+    role.can?(:manage_federation)
+  end
+
+  def create?
+    role.can?(:manage_federation)
+  end
+
+  def update?
+    role.can?(:manage_federation)
+  end
+
+  def destroy?
+    role.can?(:manage_federation)
+  end
+end
diff --git a/app/views/admin/fasp/debug/callbacks/_callback.html.haml b/app/views/admin/fasp/debug/callbacks/_callback.html.haml
new file mode 100644
index 0000000000..6b6d5cfd04
--- /dev/null
+++ b/app/views/admin/fasp/debug/callbacks/_callback.html.haml
@@ -0,0 +1,10 @@
+%tr
+  %td= callback.fasp_provider.name
+  %td= callback.fasp_provider.base_url
+  %td= callback.ip
+  %td
+    %time.relative-formatted{ datetime: callback.created_at.iso8601 }
+  %td
+    %code= callback.request_body
+  %td
+    = table_link_to 'close', t('admin.fasp.debug.callbacks.delete'), admin_fasp_debug_callback_path(callback), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/admin/fasp/debug/callbacks/index.html.haml b/app/views/admin/fasp/debug/callbacks/index.html.haml
new file mode 100644
index 0000000000..7f8164b33c
--- /dev/null
+++ b/app/views/admin/fasp/debug/callbacks/index.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+  = t('admin.fasp.debug.callbacks.title')
+
+- content_for :heading do
+  %h2= t('admin.fasp.providers.title')
+  = render 'admin/fasp/shared/links'
+
+- unless @callbacks.empty?
+  %hr.spacer
+
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('admin.fasp.providers.name')
+          %th= t('admin.fasp.providers.base_url')
+          %th= t('admin.fasp.debug.callbacks.ip')
+          %th= t('admin.fasp.debug.callbacks.created_at')
+          %th= t('admin.fasp.debug.callbacks.request_body')
+          %th
+      %tbody
+        = render partial: 'callback', collection: @callbacks
diff --git a/app/views/admin/fasp/providers/_provider.html.haml b/app/views/admin/fasp/providers/_provider.html.haml
new file mode 100644
index 0000000000..1591fc4e8c
--- /dev/null
+++ b/app/views/admin/fasp/providers/_provider.html.haml
@@ -0,0 +1,17 @@
+%tr
+  %td= provider.name
+  %td= provider.base_url
+  %td
+    - if provider.confirmed?
+      = t('admin.fasp.providers.active')
+    - else
+      = t('admin.fasp.providers.registration_requested')
+  %td
+    - unless provider.confirmed?
+      = table_link_to 'link', t('admin.fasp.providers.finish_registration'), new_admin_fasp_provider_registration_path(provider)
+    - if provider.sign_in_url.present?
+      = table_link_to 'link', t('admin.fasp.providers.sign_in'), provider.sign_in_url, target: '_blank'
+    - if provider.capability?('callback')
+      = table_link_to 'link', t('admin.fasp.providers.callback'), admin_fasp_provider_debug_calls_path(provider), data: { method: :post }
+
+    = table_link_to 'close', t('admin.providers.delete'), admin_fasp_provider_path(provider), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/admin/fasp/providers/edit.html.haml b/app/views/admin/fasp/providers/edit.html.haml
new file mode 100644
index 0000000000..ed9496b11c
--- /dev/null
+++ b/app/views/admin/fasp/providers/edit.html.haml
@@ -0,0 +1,21 @@
+- content_for :page_title do
+  = t('admin.fasp.providers.edit')
+
+= simple_form_for [:admin, @provider] do |f|
+  = render 'shared/error_messages', object: @provider
+
+  %h4= t('admin.fasp.providers.select_capabilities')
+
+  - f.object.capabilities.each do |capability|
+    .fields-group
+      .input.with_label.boolean.optional.field_with_hint
+        .label_input
+          %label.boolean.optional{ for: "provider_enabled_capabilities_#{capability['id']}" }
+            = capability['id']
+        .label_input__wrapper
+          = hidden_field_tag "provider[enabled_capabilities][#{capability['id']}]", '0', id: false
+          %label.checkbox
+            = check_box_tag "provider[enabled_capabilities][#{capability['id']}]", class: 'optional boolean'
+
+  .actions
+    = f.button :button, t('admin.fasp.providers.save'), type: :submit
diff --git a/app/views/admin/fasp/providers/index.html.haml b/app/views/admin/fasp/providers/index.html.haml
new file mode 100644
index 0000000000..8d11dd633c
--- /dev/null
+++ b/app/views/admin/fasp/providers/index.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('admin.fasp.providers.title')
+
+- content_for :heading do
+  %h2= t('admin.fasp.providers.title')
+  = render 'admin/fasp/shared/links'
+
+- unless @providers.empty?
+  %hr.spacer
+
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('admin.fasp.providers.name')
+          %th= t('admin.fasp.providers.base_url')
+          %th= t('admin.fasp.providers.status')
+          %th
+      %tbody
+        = render partial: 'provider', collection: @providers
diff --git a/app/views/admin/fasp/registrations/new.html.haml b/app/views/admin/fasp/registrations/new.html.haml
new file mode 100644
index 0000000000..d0dfc61b53
--- /dev/null
+++ b/app/views/admin/fasp/registrations/new.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('admin.fasp.providers.registrations.title')
+
+%p= t('admin.fasp.providers.registrations.description')
+
+%table.table.inline-table
+  %tbody
+    %tr
+      %th= t('admin.fasp.providers.name')
+      %td= @provider.name
+    %tr
+      %th= t('admin.fasp.providers.public_key_fingerprint')
+      %td
+        %code= @provider.provider_public_key_fingerprint
+
+= form_with url: admin_fasp_provider_registration_path(@provider), class: :simple_form do |_form|
+  .fields-group
+    .actions
+      = link_to t('admin.fasp.providers.registrations.reject'), admin_fasp_provider_path(@provider), data: { method: :delete }, class: 'button negative'
+      %button.button= t('admin.fasp.providers.registrations.confirm')
diff --git a/app/views/admin/fasp/shared/_links.html.haml b/app/views/admin/fasp/shared/_links.html.haml
new file mode 100644
index 0000000000..d0c29bde6a
--- /dev/null
+++ b/app/views/admin/fasp/shared/_links.html.haml
@@ -0,0 +1,5 @@
+.content__heading__tabs
+  = render_navigation renderer: :links do |primary|
+    :ruby
+      primary.item :providers, safe_join([material_symbol('description'), t('admin.fasp.providers.providers')]), admin_fasp_providers_path
+      primary.item :debug_callbacks, safe_join([material_symbol('group'), t('admin.fasp.debug.callbacks.title')]), admin_fasp_debug_callbacks_path
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4302d1f536..3ffe81a0bb 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -559,6 +559,36 @@ en:
       total_storage: Media attachments
       totals_time_period_hint_html: The totals displayed below include data for all time.
       unknown_instance: There is currently no record of this domain on this server.
+    fasp:
+      debug:
+        callbacks:
+          created_at: Created at
+          delete: Delete
+          ip: IP address
+          request_body: Request body
+          title: Debug Callbacks
+      providers:
+        active: Active
+        base_url: Base URL
+        debug: Debug call
+        delete: Delete
+        edit: Edit Provider
+        finish_registration: Finish registration
+        name: Name
+        public_key_fingerprint: Public key fingerprint
+        providers: Providers
+        registration_requested: Registration requested
+        registrations:
+          confirm: Confirm
+          description: You received a registration from a FASP. Reject it if you did not initiate this. If you initiated this, carefully compare name and key fingerprint before confirming the registration.
+          reject: Reject
+          title: Confirm FASP Registration
+        save: Save
+        select_capabilities: Select Capabilities
+        sign_in: Sign In
+        status: Status
+        title: Fediverse Auxiliary Service Providers
+      title: FASP
     invites:
       deactivate_all: Deactivate all
       filter:
diff --git a/config/navigation.rb b/config/navigation.rb
index 225106592c..e1213c9734 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -73,6 +73,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :announcements, safe_join([material_symbol('campaign'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) }
       s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) }
       s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) }
+      s.item :fasp, safe_join([material_symbol('captive_portal'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) }
       s.item :relays, safe_join([material_symbol('captive_portal'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) }
     end
 
diff --git a/config/routes.rb b/config/routes.rb
index e31fbcb06d..5b130c517b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -196,6 +196,8 @@ Rails.application.routes.draw do
 
   draw(:api)
 
+  draw(:fasp)
+
   draw(:web_app)
 
   get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false
diff --git a/config/routes/fasp.rb b/config/routes/fasp.rb
new file mode 100644
index 0000000000..9d052526de
--- /dev/null
+++ b/config/routes/fasp.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+namespace :api, format: false do
+  namespace :fasp do
+    namespace :debug do
+      namespace :v0 do
+        namespace :callback do
+          resources :responses, only: [:create]
+        end
+      end
+    end
+
+    resource :registration, only: [:create]
+  end
+end
+
+namespace :admin do
+  namespace :fasp do
+    namespace :debug do
+      resources :callbacks, only: [:index, :destroy]
+    end
+
+    resources :providers, only: [:index, :show, :edit, :update, :destroy] do
+      resources :debug_calls, only: [:create]
+
+      resource :registration, only: [:new, :create]
+    end
+  end
+end
diff --git a/db/migrate/20241205103523_create_fasp_providers.rb b/db/migrate/20241205103523_create_fasp_providers.rb
new file mode 100644
index 0000000000..69e211f275
--- /dev/null
+++ b/db/migrate/20241205103523_create_fasp_providers.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class CreateFaspProviders < ActiveRecord::Migration[7.2]
+  def change
+    create_table :fasp_providers do |t|
+      t.boolean :confirmed, null: false, default: false
+      t.string :name, null: false
+      t.string :base_url, null: false, index: { unique: true }
+      t.string :sign_in_url
+      t.string :remote_identifier, null: false
+      t.string :provider_public_key_pem, null: false
+      t.string :server_private_key_pem, null: false
+      t.jsonb :capabilities
+      t.jsonb :privacy_policy
+      t.string :contact_email
+      t.string :fediverse_account
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20241206131513_create_fasp_debug_callbacks.rb b/db/migrate/20241206131513_create_fasp_debug_callbacks.rb
new file mode 100644
index 0000000000..0542b2d353
--- /dev/null
+++ b/db/migrate/20241206131513_create_fasp_debug_callbacks.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CreateFaspDebugCallbacks < ActiveRecord::Migration[7.2]
+  def change
+    create_table :fasp_debug_callbacks do |t|
+      t.references :fasp_provider, null: false, foreign_key: true
+      t.string :ip
+      t.text :request_body
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 32d94b48ec..2563664920 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -445,6 +445,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
     t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
   end
 
+  create_table "fasp_debug_callbacks", force: :cascade do |t|
+    t.bigint "fasp_provider_id", null: false
+    t.string "ip"
+    t.text "request_body"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id"
+  end
+
+  create_table "fasp_providers", force: :cascade do |t|
+    t.boolean "confirmed", default: false, null: false
+    t.string "name", null: false
+    t.string "base_url", null: false
+    t.string "sign_in_url"
+    t.string "remote_identifier", null: false
+    t.string "provider_public_key_pem", null: false
+    t.string "server_private_key_pem", null: false
+    t.jsonb "capabilities"
+    t.jsonb "privacy_policy"
+    t.string "contact_email"
+    t.string "fediverse_account"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true
+  end
+
   create_table "favourites", force: :cascade do |t|
     t.datetime "created_at", precision: nil, null: false
     t.datetime "updated_at", precision: nil, null: false
@@ -1289,6 +1315,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
   add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
   add_foreign_key "custom_filters", "accounts", on_delete: :cascade
   add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
+  add_foreign_key "fasp_debug_callbacks", "fasp_providers"
   add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
   add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
   add_foreign_key "featured_tags", "accounts", on_delete: :cascade
diff --git a/spec/fabricators/fasp/debug_callback_fabricator.rb b/spec/fabricators/fasp/debug_callback_fabricator.rb
new file mode 100644
index 0000000000..a474c78cb8
--- /dev/null
+++ b/spec/fabricators/fasp/debug_callback_fabricator.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Fabricator('Fasp::DebugCallback') do
+  fasp_provider nil
+  ip            'MyString'
+  request_body  'MyText'
+end
diff --git a/spec/fabricators/fasp/provider_fabricator.rb b/spec/fabricators/fasp/provider_fabricator.rb
new file mode 100644
index 0000000000..7ee508619a
--- /dev/null
+++ b/spec/fabricators/fasp/provider_fabricator.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Fabricator('Fasp::Provider') do
+  name                    'MyString'
+  base_url                'MyString'
+  sign_in_url             'MyString'
+  remote_identifier       'MyString'
+  provider_public_key_pem 'MyString'
+  server_private_key_pem  'MyString'
+  capabilities            ''
+  privacy_policy          ''
+  contact_email           'MyString'
+  fediverse_account       'MyString'
+end
diff --git a/spec/models/fasp/debug_callback_spec.rb b/spec/models/fasp/debug_callback_spec.rb
new file mode 100644
index 0000000000..f3cca6fade
--- /dev/null
+++ b/spec/models/fasp/debug_callback_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Fasp::DebugCallback do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/fasp/provider_spec.rb b/spec/models/fasp/provider_spec.rb
new file mode 100644
index 0000000000..d3d528efb4
--- /dev/null
+++ b/spec/models/fasp/provider_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Fasp::Provider do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/policies/admin/fasp/provider_policy_spec.rb b/spec/policies/admin/fasp/provider_policy_spec.rb
new file mode 100644
index 0000000000..d6cec950ea
--- /dev/null
+++ b/spec/policies/admin/fasp/provider_policy_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::Fasp::ProviderPolicy, type: :policy do
+  subject { described_class }
+
+  let(:user) { User.new }
+
+  permissions '.scope' do
+    pending "add some examples to (or delete) #{__FILE__}"
+  end
+
+  permissions :show? do
+    pending "add some examples to (or delete) #{__FILE__}"
+  end
+
+  permissions :create? do
+    pending "add some examples to (or delete) #{__FILE__}"
+  end
+
+  permissions :update? do
+    pending "add some examples to (or delete) #{__FILE__}"
+  end
+
+  permissions :destroy? do
+    pending "add some examples to (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/admin/fasp/debug/callbacks_spec.rb b/spec/requests/admin/fasp/debug/callbacks_spec.rb
new file mode 100644
index 0000000000..8e8eee3e6e
--- /dev/null
+++ b/spec/requests/admin/fasp/debug/callbacks_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Fasp::Debug::Callbacks' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/admin/fasp/debug_calls_spec.rb b/spec/requests/admin/fasp/debug_calls_spec.rb
new file mode 100644
index 0000000000..a21053799b
--- /dev/null
+++ b/spec/requests/admin/fasp/debug_calls_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Fasp::DebugCalls' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/admin/fasp/providers_spec.rb b/spec/requests/admin/fasp/providers_spec.rb
new file mode 100644
index 0000000000..1c0d49f1be
--- /dev/null
+++ b/spec/requests/admin/fasp/providers_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Fasp::Providers' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/admin/fasp/registrations_spec.rb b/spec/requests/admin/fasp/registrations_spec.rb
new file mode 100644
index 0000000000..b465d50a55
--- /dev/null
+++ b/spec/requests/admin/fasp/registrations_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Fasp::Registrations' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb
new file mode 100644
index 0000000000..a94e4a1b91
--- /dev/null
+++ b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end
diff --git a/spec/requests/api/fasp/registrations_spec.rb b/spec/requests/api/fasp/registrations_spec.rb
new file mode 100644
index 0000000000..2504e70489
--- /dev/null
+++ b/spec/requests/api/fasp/registrations_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::Fasp::Registrations' do
+  describe 'GET /index' do
+    pending "add some examples (or delete) #{__FILE__}"
+  end
+end