Developing Audit Logs with Duende IdentityServer Events

Maarten Balliauw |

In regulated industries like finance and healthcare, "knowing what happened" is often just as critical as preventing bad things from happening. Frameworks like SOC 2 and HIPAA don't just ask you to secure your systems; they ask you to prove it. That means structured, queryable, tamper-evident records of security events: who logged in, when a token was issued, which client authenticated, and what failed.

Standard application logs aren't built for this. They're noisy, unstructured, and designed for developers to debug issues. But not for reviewing access patterns across six months of production traffic.

Duende IdentityServer ships with a structured eventing system that addresses this gap directly. Architecturally, this means a clean separation between high-volume operational logs and the dedicated, low-volume security events that form the official record.

In this post, we'll walk through how you, as a developer, can use Duende IdentityServer's events to build an audit trail that satisfies compliance requirements in Highly Regulated Industries (HRI).

Why Standard Logging Falls Short

ASP.NET Core's built-in logging system is excellent for development and operational troubleshooting. IdentityServer writes detailed logs under the Duende.IdentityServer category at various levels (Trace, Debug, Information, Warning, Error, and Critical). During development, these logs are invaluable for understanding the internal flow and decision-making of your IdentityServer implementation.

In production, however, we recommend setting the default to the Warning level, because lower levels produce too much data. That's sensible operational advice, but it creates a compliance problem: most security-relevant information, such as successful logins and token issuance, lives at the Information level and below. Turn it on, and you're drowning in noise. Turn it off, and you have nothing to work with for auditing purposes.

Beyond the compliance gap, this unnecessary log volume creates significant operational overhead and unnecessarily drives up costs for log aggregation and retention.

This is where Duende IdentityServer's eventing system comes in. It sits at a higher abstraction level than standard logging. Events are structured data with event IDs, success/failure information, categories, and contextual details. They are purpose-built for auditing, and can be forwarded to structured logging stores like ELK, Seq, or Splunk, where you can query these details.

Enabling Events In IdentityServer

Events in Duende IdentityServer are not turned on by default. You enable them globally when configuring IdentityServer when your application starts:

// Program.cs
builder.Services.AddIdentityServer(options =>
{
    options.Events.RaiseSuccessEvents = true;
    options.Events.RaiseFailureEvents = true;
    options.Events.RaiseErrorEvents = true;
    options.Events.RaiseInformationEvents = true;
});

Each flag controls a category of events:

  • RaiseSuccessEvents are raised on properly formed, valid requests processed without errors (e.g., UserLoginSuccessEvent, TokenIssuedSuccessEvent).
  • RaiseFailureEvents are raised when an action fails due to incorrect or badly formed parameters (e.g., UserLoginFailureEvent, ClientAuthenticationFailureEvent).
  • RaiseErrorEvents are raised on invalid configuration or unhandled exceptions.
  • RaiseInformationEvents are raised for actions of informational interest, such as consent grants and denials.

For a compliance-focused deployment, you can enable all four. The event volume is far lower than verbose logging, and each event carries a meaningful security context that will be helpful in an HRI setting.

The Built-in Event Catalog

IdentityServer defines a focused set of events that map directly to what auditors look for. Crucially for developers, all of these events are available as strong-typed data through classes found in the Duende.IdentityServer.Events namespace, making them straightforward to work with and inspect their properties:

Event Description
ApiAuthenticationFailureEvent and ApiAuthenticationSuccessEvent Successful/failed API authentication at the introspection endpoint.
ClientAuthenticationSuccessEvent and ClientAuthenticationFailureEvent Successful/failed client authentication at the token endpoint.
TokenIssuedSuccessEvent and TokenIssuedFailureEvent Successful/failed attempts to request identity tokens, access tokens, refresh tokens and authorization codes.
TokenIntrospectionSuccessEventand TokenIntrospectionFailureEvent Successful token introspection requests.
TokenRevokedSuccessEvent Successful token revocation requests.
UserLoginSuccessEvent and UserLoginFailureEvent Raised by the quickstart UI for successful/failed user logins. We'll look at this in more detail later.
UserLogoutSuccessEvent Successful logout requests.
ConsentGrantedEvent and ConsentDeniedEvent User grants/denied consent.
DeviceAuthorizationFailureEvent and DeviceAuthorizationSuccessEvent Successful/failed device authorization requests.
UnhandledExceptionEvent Raised for unhandled exceptions.

For compliance controls in HRI, you can map these events to the various frameworks:

  • Authentication auditing (SOC 2 CC6.1, HIPAA §164.312(d)): UserLoginSuccessEvent, UserLoginFailureEvent, UserLogoutSuccessEvent
  • Token lifecycle (SOC 2 CC6.3): TokenIssuedSuccessEvent, TokenIssuedFailureEvent, TokenRevokedSuccessEvent
  • Authorization decisions (HIPAA §164.312(a)(1)): ConsentGrantedEvent, ConsentDeniedEvent
  • Client and API authentication: ClientAuthenticationSuccessEvent, ClientAuthenticationFailureEvent, ApiAuthenticationSuccessEvent, ApiAuthenticationFailureEvent

We'll look at how to subscribe to these events in a bit, but let's first look at how they are raised.

Raising Events In Your Code

Duende IdentityServer automatically raises most built-in events. For events tied to your login UI, such as UserLoginSuccessEvent and UserLoginFailureEvent, you will have to raise them explicitly using the IEventService:

// Pages/Account/Login.cshtml.cs
public class LoginModel : PageModel
{
    private readonly IEventService _events;
    private readonly TestUserStore _users;

    public LoginModel(IEventService events, TestUserStore users)
    {
        _events = events;
        _users = users;
    }

    [BindProperty]
    public LoginInputModel Input { get; set; }

    public async Task<IActionResult> OnPost()
    {
        if (_users.ValidateCredentials(Input.Username, Input.Password))
        {
            var user = _users.FindByUsername(Input.Username);
            
            // Raise the success event
            await _events.RaiseAsync(
                new UserLoginSuccessEvent(
                    user.Username, user.SubjectId, user.Username));

            // ... issue authentication cookie...
        }
        else
        {
            // Raise the failure event
            await _events.RaiseAsync(
                new UserLoginFailureEvent(
                    Input.Username, "invalid credentials"));

            // ... return error...
        }
    }
}

This is important: because login UI logic is your code, IdentityServer can't raise these events for you. If you skip this step, your audit trail has a gap in authentication events, precisely the ones auditors care most about.

Implementing a Custom IEventSink for Audit Storage

To subscribe to events being raised, you'll want to create an implementation of IEventSink that forwards events to your audit store. The default IEventSink serializes each event to JSON and forwards it to ASP.NET Core's logging system. For a real audit trail, you want events flowing to a dedicated, tamper-evident store.

Here's a simple implementation that writes events to Seq:

// AuditEventSink.cs
public class AuditEventSink : IEventSink
{
    private readonly Logger _log;

    public AuditEventSink()
    {
        _log = new LoggerConfiguration()
            .WriteTo.Seq("https://seq.internal.example.com")
            .CreateLogger();
    }

    public Task PersistAsync(Event evt)
    {
        // Forward to Seq via logging.
        // Note you'll want to use a sink with zero tolerance
        // for dropping audit data.
        if (evt.EventType == EventTypes.Success ||
            evt.EventType == EventTypes.Information)
        {
            _log.Information("{Name} ({Id}), Details: {@details}",
                evt.Name, evt.Id, evt);
        }
        else
        {
            _log.Error("{Name} ({Id}), Details: {@details}",
                evt.Name, evt.Id, evt);
        }

        return Task.CompletedTask;
    }
}

Note: The @details syntax in Serilog destructures the entire event object, capturing all structured properties — subject IDs, client IDs, scopes, grant types, and timestamps — without you having to extract them manually.

For Duende IdentityServer to use your implementation, you'll need to register it with the service provider:

// Program.cs
builder.Services.AddTransient<IEventSink, AuditEventSink>();

For a production audit trail, consider these additional measures:

  • Write to an append-only store. Seq, Elasticsearch with policies to retain all data (for example, Index Lifecycle Management in ElasticSearch), or a dedicated SQL table with no DELETE permissions, all work. The key is that once written, events can't be modified or removed by application code.
  • Include a cryptographic hash chain. For true tamper evidence, each event's hash can include the previous event's hash, forming a verifiable chain. This isn't built into IdentityServer's eventing, but it's something you'd add in your IEventSink implementation.
  • Separate audit storage from operational logs. Auditors shouldn't need access to your general application logs, and operators shouldn't need access to the audit trail.

Creating Custom Events

The built-in catalog covers the core protocol events. For capturing domain-specific governance and access controls, custom events are your architectural extensibility hook. You can create custom events by deriving from the Event base class:

// SensitiveDataAccessEvent.cs
public class SensitiveDataAccessEvent : Event
{
    public SensitiveDataAccessEvent(string subjectId, string resource)
        : base(EventCategories.Authentication,
               "Sensitive Data Access",
               EventTypes.Information,
               99001)
    {
        SubjectId = subjectId;
        Resource = resource;
    }

    public string SubjectId { get; set; }
    public string Resource { get; set; }
}

Raise it anywhere you have access to IEventService:

// Usage
await _events.RaiseAsync(
    new SensitiveDataAccessEvent(user.SubjectId, "patient-records"));

Custom events now flow through the same IEventSink pipeline as built-in events.

The Compliance Connection: FAPI 2.0 and Healthcare

If you're building for healthcare, financial services, or other HRIs, you're likely already looking at the FAPI 2.0 Security Profile. Duende IdentityServer is FAPI 2.0 certified by the OpenID Foundation, which means the protocol-level security (DPoP, PAR, sender-constrained tokens, ...) is handled for you.

FAPI 2.0 compliance is about the protocol. Auditing requirements in regulated industries go beyond the protocol. SOC 2 Type II, for example, requires demonstrating that controls operated effectively over a period of time, which means you need a continuous record of authentication and authorization events. HIPAA's Security Rule (§164.312) requires audit controls that record and examine activity in systems that contain electronic Protected Health Information (ePHI).

The eventing system provides the raw material for both. A well-implemented IEventSink gives you:

  • Who authenticated (SubjectId from login events)
  • When it happened (event timestamps)
  • How they authenticated (client IDs, grant types from token events)
  • What failed and why (failure events with error details)
  • What was authorized (scopes and resources from token issuance events)

This is the data auditors need. IdentityServer's events provide a structured, queryable foundation. Your IEventSink implementation delivers it to the right store in the right format.

Conclusion

Audit logging for compliance isn't about capturing everything. It's about capturing the right things in a structured, queryable, and tamper-evident format. Duende IdentityServer's eventing system gives you a clean separation between operational logging (noisy, developer-focused) and security events (structured, auditor-focused).

Enable all four event categories, implement IEventSink to route events to a dedicated audit store, raise login events explicitly in your UI code, and add custom events for domain-specific access tracking. That's the foundation for satisfying audit requirements in SOC 2, HIPAA, and other regulatory frameworks — without drowning your logging infrastructure in noise.

For more details on the eventing system, see the events documentation. If you're building for high-value scenarios in healthcare or finance, the FAPI 2.0 guide covers the protocol-level requirements, and the HelseID customer story shows how the Norwegian Health Network uses Duende IdentityServer to secure healthcare identity at a national scale 🇳🇴.