Audit Log Tutorial
View SourceThe 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
With Igniter (recommended)
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
endThe 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
endGenerate 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
endThat'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
endExclude 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
endExclude 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
endCustomise 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
endConfigure 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
endTo disable batching entirely (writes happen immediately):
audit_log do
write_batching do
enabled? false
end
endConfigure 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
endAvailable 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
endThe IP privacy transformation applies to all IP-related fields in the request metadata:
remote_ip- The direct client IPx_forwarded_for- Proxy chain IPsforwarded- 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
endThe 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 entrysubject- 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 instancelogged_at- When the event occurredaction_name- The action that triggered the eventstatus-:success,:failure, or:unknownextra_data- Additional information including:actor- The actor performing the action (if any)tenant- The tenant context (if using multi-tenancy)request- Request metadataparams- 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_modeoption.:hashneeds a secret salt, and the digests it stores are still personal data. Prefer:truncateor:excludewhere 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