diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj
index 08d182b..50d99ab 100644
--- a/tests/acceptance/Acceptance.csproj
+++ b/tests/acceptance/Acceptance.csproj
@@ -9,6 +9,8 @@
+
+
@@ -20,6 +22,7 @@
+
diff --git a/tests/acceptance/Features/BffToegang.feature b/tests/acceptance/Features/BffToegang.feature
new file mode 100644
index 0000000..85c0e50
--- /dev/null
+++ b/tests/acceptance/Features/BffToegang.feature
@@ -0,0 +1,22 @@
+# language: en
+# Drives S-07 (#8). The BFF is the portals' only backend (ยง8.3): it validates Keycloak digid tokens
+# on the self-service submit and serves the openbaar register anonymously with only public-safe
+# fields (ADR-0010). Tokens are minted with a local test key; real Keycloak validation is verify-bff.
+Feature: Toegang via de BFF
+ Als portaal wil ik uitsluitend via de BFF met de backend praten
+ zodat tokenvalidatie en veilige velden centraal geregeld zijn.
+
+ Scenario: Indienen met een geldig token wordt doorgezet naar het domein
+ Given a zorgprofessional with a valid DigiD token for BSN "123456782"
+ When they submit a registration via the BFF
+ Then the BFF accepts it and forwards BSN "123456782" to the domain
+
+ Scenario: Indienen zonder token wordt geweigerd
+ Given a caller without a token
+ When they submit a registration via the BFF
+ Then the BFF rejects it as unauthorized
+
+ Scenario: Het openbaar register is anoniem en toont geen BSN
+ Given the projection contains a zaak "abc-111" for BSN "123456782"
+ When the openbaar register is queried anonymously
+ Then the response lists "abc-111" without exposing the BSN
diff --git a/tests/acceptance/Steps/BffToegangSteps.cs b/tests/acceptance/Steps/BffToegangSteps.cs
new file mode 100644
index 0000000..7e19e04
--- /dev/null
+++ b/tests/acceptance/Steps/BffToegangSteps.cs
@@ -0,0 +1,83 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Acceptance.Support;
+using Bff.Api;
+using Reqnroll;
+using Xunit;
+
+namespace Acceptance.Steps;
+
+/// Bindings for BffToegang.feature (S-07). Drives the real BFF over HTTP with a
+/// fake domain + projection and locally-minted tokens (ADR-0010). One host per scenario.
+[Binding]
+public sealed class BffToegangSteps : IDisposable
+{
+ private readonly BffAcceptanceHost _host = new();
+ private HttpClient _client = null!;
+ private string? _token;
+ private HttpResponseMessage? _response;
+
+ [Given("a zorgprofessional with a valid DigiD token for BSN \"(.*)\"")]
+ public void GivenAValidToken(string bsn)
+ {
+ _token = BffAcceptanceHost.MintToken(bsn);
+ _client = _host.CreateClient();
+ }
+
+ [Given("a caller without a token")]
+ public void GivenNoToken() => _client = _host.CreateClient();
+
+ [Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
+ public void GivenTheProjectionContains(string zaakId, string bsn)
+ {
+ _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null));
+ _client = _host.CreateClient();
+ }
+
+ [When("they submit a registration via the BFF")]
+ public async Task WhenTheySubmit()
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
+ {
+ Content = JsonContent.Create(new { }),
+ };
+ if (_token is not null)
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ _response = await _client.SendAsync(request);
+ }
+
+ [When("the openbaar register is queried anonymously")]
+ public async Task WhenTheOpenbaarRegisterIsQueried()
+ => _response = await _client.GetAsync("/openbaar/register");
+
+ [Then("the BFF accepts it and forwards BSN \"(.*)\" to the domain")]
+ public void ThenAcceptedAndForwarded(string bsn)
+ {
+ Assert.Equal(HttpStatusCode.Accepted, _response!.StatusCode);
+ Assert.Equal(bsn, _host.Domain.SubmittedBsn);
+ }
+
+ [Then("the BFF rejects it as unauthorized")]
+ public void ThenUnauthorized()
+ {
+ Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode);
+ Assert.Null(_host.Domain.SubmittedBsn);
+ }
+
+ [Then("the response lists \"(.*)\" without exposing the BSN")]
+ public async Task ThenListsWithoutBsn(string zaakId)
+ {
+ Assert.Equal(HttpStatusCode.OK, _response!.StatusCode);
+ var body = await _response.Content.ReadAsStringAsync();
+ Assert.Contains(zaakId, body);
+ Assert.DoesNotContain("123456782", body);
+ }
+
+ public void Dispose()
+ {
+ _response?.Dispose();
+ _client?.Dispose();
+ _host.Dispose();
+ }
+}
diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs
new file mode 100644
index 0000000..dcd94a2
--- /dev/null
+++ b/tests/acceptance/Support/BffAcceptanceHost.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using Bff.Api;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Acceptance.Support;
+
+/// Hosts the real BFF in-process for the acceptance scenario, with fake downstreams and a
+/// local test signing key so tokens can be minted without a live Keycloak (ADR-0010).
+public sealed class BffAcceptanceHost : WebApplicationFactory
+{
+ private static readonly SymmetricSecurityKey TestKey =
+ new(Encoding.UTF8.GetBytes("acceptance-bff-signing-key-at-least-256-bits-long!!"));
+
+ public CapturingDomainClient Domain { get; } = new();
+ public StubProjectionClient Projection { get; } = new();
+
+ public static string MintToken(string bsn) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
+ {
+ Claims = new Dictionary { ["bsn"] = bsn },
+ Expires = DateTime.UtcNow.AddMinutes(30),
+ SigningCredentials = new SigningCredentials(TestKey, SecurityAlgorithms.HmacSha256),
+ });
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
+ builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
+ builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
+
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton(Domain);
+ services.AddSingleton(Projection);
+ services.Configure(JwtBearerDefaults.AuthenticationScheme, options =>
+ {
+ options.Authority = null;
+ options.MetadataAddress = null!;
+ options.RequireHttpsMetadata = false;
+ options.Configuration = new OpenIdConnectConfiguration();
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = false,
+ ValidateAudience = false,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = TestKey,
+ ClockSkew = TimeSpan.Zero,
+ };
+ });
+ });
+ }
+}
+
+/// Records the bsn the BFF forwarded.
+public sealed class CapturingDomainClient : IDomainClient
+{
+ public string? SubmittedBsn { get; private set; }
+
+ public Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
+ {
+ SubmittedBsn = bsn;
+ return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
+ }
+}
+
+/// Serves configurable projection rows.
+public sealed class StubProjectionClient : IProjectionClient
+{
+ public List Entries { get; } = [];
+
+ public Task> GetRegisterAsync(CancellationToken ct = default)
+ => Task.FromResult>(Entries);
+}