Audit Log Tutorial

View Source

The audit log add-on provides automatic logging of authentication events (sign in, registration, failures, etc.) to help you track security-relevant activities in your application.

Installation

Use mix ash_authentication.add_add_on audit_log to automatically set up audit logging:

mix ash_authentication.add_add_on audit_log

This will:

  • Create the audit log resource
  • Add the add-on to your user resource
  • Ensure the AshAuthentication.Supervisor is in your application supervision tree
  • Generate and run migrations

You can customise the installation with options:

# Custom audit log resource name
mix ash_authentication.add_add_on audit_log --audit-log MyApp.Accounts.AuthAuditLog

# Include sensitive fields
mix ash_authentication.add_add_on audit_log --include-fields email,username

# Exclude specific strategies
mix ash_authentication.add_add_on audit_log --exclude-strategies magic_link,oauth

# Exclude specific actions
mix ash_authentication.add_add_on audit_log --exclude-actions sign_in_with_token

Manually

If you prefer to set up audit logging manually, continue with the steps below:

Create the audit log resource

First, create a resource to store the audit logs. This resource uses the AshAuthentication.AuditLogResource extension which handles all the necessary setup:

defmodule MyApp.Accounts.AuditLog do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication.AuditLogResource],
    domain: MyApp.Accounts

  postgres do
    table "account_audit_logs"
    repo MyApp.Repo
  end
end

The extension automatically creates all required attributes and actions. You don't need to define any manually unless you want to customise them.

Add the audit log add-on to your user resource

Next, add the audit log add-on to your user resource's authentication configuration:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    extensions: [AshAuthentication],
    domain: MyApp.Accounts

  attributes do
    uuid_primary_key :id
    attribute :email, :ci_string, allow_nil?: false, public?: true, sensitive?: true
    attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
  end

  authentication do
    tokens do
      enabled? true
      token_resource MyApp.Accounts.Token
    end

    add_ons do
      audit_log do
        audit_log_resource MyApp.Accounts.AuditLog
      end
    end

    strategies do
      password :password do
        identity_field :email
      end
    end
  end

  identities do
    identity :unique_email, [:email]
  end
end

Generate and run migrations

Generate migrations for the audit log table:

mix ash.codegen create_accounts_audit_logs
mix ash.migrate

Start the audit log batcher

The audit log uses batched writes to reduce database load. Add the AshAuthentication.Supervisor to your application's supervision tree:

# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.Repo,
      # Add this line
      {AshAuthentication.Supervisor, otp_app: :my_app}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

That's it! Authentication events will now be logged automatically.

What gets logged?

The audit log automatically tracks:

  • Successful and failed authentication attempts
  • User registration events
  • The authentication strategy used (password, OAuth2, magic link, etc.)
  • The action name that triggered the event
  • User subject (when available)
  • Timestamp of the event
  • Non-sensitive parameters from the request
  • Sensitive parameters that are explicitly configured

Viewing audit logs

You can read audit logs like any other Ash resource:

# Get all audit logs
MyApp.Accounts.AuditLog
|> Ash.read!()

# Filter by user
MyApp.Accounts.AuditLog
|> Ash.Query.filter(subject == ^user_subject)
|> Ash.read!()

# Filter by action
MyApp.Accounts.AuditLog
|> Ash.Query.filter(action_name == :sign_in_with_password)
|> Ash.read!()

# Filter by status
MyApp.Accounts.AuditLog
|> Ash.Query.filter(status == :failure)
|> Ash.read!()

Configuration options

Include sensitive fields

By default, sensitive arguments and attributes (marked with sensitive?: true) are filtered out of the audit logs. You can explicitly include specific fields:

authentication do
  add_ons do
    audit_log do
      audit_log_resource MyApp.Accounts.AuditLog
      include_fields [:email, :username]
    end
  end
end

Exclude specific strategies

If you want to exclude certain authentication strategies from being logged:

authentication do
  add_ons do
    audit_log do
      audit_log_resource MyApp.Accounts.AuditLog
      exclude_strategies [:magic_link]
    end
  end
end

Exclude specific actions

To exclude specific actions from being logged:

authentication do
  add_ons do
    audit_log do
      audit_log_resource MyApp.Accounts.AuditLog
      exclude_actions [:sign_in_with_token]
    end
  end
end

Customise log retention

By default, audit logs are retained for 90 days. You can change this or disable automatic cleanup:

defmodule MyApp.Accounts.AuditLog do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication.AuditLogResource],
    domain: MyApp.Accounts

  audit_log do
    # Keep logs for 30 days
    log_lifetime 30

    # Or disable automatic cleanup
    # log_lifetime :infinity
  end

  postgres do
    table "account_audit_log"
    repo MyApp.Repo
  end
end

Configure write batching

The audit log batches writes to reduce database load. You can customise this behaviour:

defmodule MyApp.Accounts.AuditLog do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication.AuditLogResource],
    domain: MyApp.Accounts

  audit_log do
    write_batching do
      enabled? true
      # Write batch every 5 seconds
      timeout :timer.seconds(5)
      # Or when batch reaches 50 records
      max_size 50
    end
  end

  postgres do
    table "account_audit_log"
    repo MyApp.Repo
  end
end

To disable batching entirely (writes happen immediately):

audit_log do
  write_batching do
    enabled? false
  end
end

Configure IP address privacy

You can control how IP addresses are stored in audit logs:

authentication do
  add_ons do
    audit_log do
      audit_log_resource MyApp.Accounts.AuditLog

      # IP privacy options: :none | :hash | :truncate | :exclude
      ip_privacy_mode :truncate

      # Network masks for truncation (optional, these are the defaults)
      ipv4_truncation_mask 24  # Keep first 3 octets
      ipv6_truncation_mask 48  # Keep first 3 segments
    end
  end
end

Available IP privacy modes:

  • :none (default) - Store IP addresses as-is without modification
  • :truncate - Truncate IP addresses to a network prefix (e.g., 192.168.1.100 → 192.168.1.0/24)
  • :exclude - Don't store IP addresses at all
  • :hash - Store a keyed digest of the IP address. This mode needs a secret salt. See Hashing IP addresses below

Use :truncate or :exclude when the audit log does not need to tell one address from another. Neither depends on a secret, so neither can be misconfigured.

When using :truncate mode, the default masks are:

  • IPv4: /24 - Keeps first 3 octets (e.g., 192.168.1.0/24)
  • IPv6: /48 - Keeps first 3 segments (e.g., 2001:db8:85a3::/48)

Example configurations:

# Truncate with more aggressive masking
audit_log do
  audit_log_resource MyApp.Accounts.AuditLog
  ip_privacy_mode :truncate
  ipv4_truncation_mask 16  # Keep first 2 octets (more privacy)
  ipv6_truncation_mask 32  # Keep first 2 segments (more privacy)
end

# Exclude IP addresses entirely
audit_log do
  audit_log_resource MyApp.Accounts.AuditLog
  ip_privacy_mode :exclude
end

The IP privacy transformation applies to all IP-related fields in the request metadata:

  • remote_ip - The direct client IP
  • x_forwarded_for - Proxy chain IPs
  • forwarded - Standard forwarded header with IP information

Hashing IP addresses

:hash stores an HMAC-SHA256 digest of the address, truncated to 64 bits. It keeps each address distinct, so you can count the events which come from one address without storing the address itself.

:hash needs a salt, and you must configure it under your own application name:

config :my_app,
  audit_log_ip_salt: System.fetch_env!("AUDIT_LOG_IP_SALT")

mix ash_authentication.add_add_on.audit_log provisions this for you: a distinct generated value in dev.exs and test.exs, and a runtime.exs entry for :prod which reads AUDIT_LOG_IP_SALT from the environment.

The salt can be a string, or a {module, function, arguments} tuple which returns a string. AshAuthentication reads it from the application which owns the resource being audited, so each application in an umbrella has its own salt.

# Hash all IP addresses. Requires the salt above
audit_log do
  audit_log_resource MyApp.Accounts.AuditLog
  ip_privacy_mode :hash
end

The salt must be secret and it must have high entropy. Generate one with mix phx.gen.secret or Base.encode64(:crypto.strong_rand_bytes(32)), then keep it in your secret store beside your other production secrets.

There is no salt which is safe to share between deployments, and there is no safe default. IPv4 has only 2^32 addresses, so anybody who knows the salt can compute the digest of every address and recover the original from a stored digest. For this reason :hash fails closed: AshAuthentication.Supervisor refuses to start when a resource selects :hash and no salt is configured.

Changing the salt changes every digest, so entries written before the change no longer correlate with entries written after it.

Deprecated salt locations

Earlier versions read the salt from this library's own application name. Both config :ash_authentication, audit_log_ip_salt: ... and config :ash_authentication, secret: ... still work, and are used when your own application configures nothing. Both are deprecated, both warn at start up, and both will be removed in a future release.

Run mix ash_authentication.upgrade to move the setting into your application's namespace. Keep the value identical — a different salt rotates every stored digest.

A digest is not anonymous data. It still singles out one subscriber, so it remains personal data under the GDPR and similar laws. Apply the same access controls, retention limits and deletion processes you would apply to raw addresses.

Audit log attributes

Each audit log entry contains:

  • id - Unique identifier for the log entry
  • subject - The authenticated user's subject string (if available)
  • strategy - The authentication strategy used (:password, :github, etc.)
  • audit_log - The name of the audit log add-on instance
  • logged_at - When the event occurred
  • action_name - The action that triggered the event
  • status - :success, :failure, or :unknown
  • extra_data - Additional information including:
    • actor - The actor performing the action (if any)
    • tenant - The tenant context (if using multi-tenancy)
    • request - Request metadata
    • params - Non-sensitive parameters from the action
  • resource - The resource module that was authenticated

Security considerations

  • Sensitive fields (passwords, tokens, API keys) are automatically filtered from audit logs unless explicitly included via include_fields
  • IP addresses can be hashed, truncated or excluded with the ip_privacy_mode option. :hash needs a secret salt, and the digests it stores are still personal data. Prefer :truncate or :exclude where per-address granularity is not needed
  • Audit logs should be stored in a resilient data layer like PostgreSQL
  • Consider setting up alerts for suspicious patterns (multiple failed logins, etc.)
  • Ensure proper access controls on the audit log resource using Ash policies
  • The audit log resource doesn't have default policies - you should add them based on your security requirements

Example: Adding policies to audit logs

defmodule MyApp.Accounts.AuditLog do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication.AuditLogResource],
    domain: MyApp.Accounts,
    authorizers: [Ash.Policy.Authorizer]

  policies do
    # Only admins can read audit logs
    policy action_type(:read) do
      authorize_if relates_to_actor_via([:user, :admin])
    end

    # Allow AshAuthentication to write logs
    policy action_type(:create) do
      authorize_if AshAuthentication.Checks.AshAuthenticationInteraction
    end
  end

  postgres do
    table "account_audit_log"
    repo MyApp.Repo
  end
end