How to Monitor Phoenix.Token with Vigilmon
Phoenix.Token provides signed and optionally encrypted tokens for stateless authentication flows in Phoenix applications. It is the standard mechanism for generating single-use links in password-reset emails, email verification flows, WebSocket connection authorization, and invite-link systems. Each token is signed with the Phoenix secret key base, carries an embedded term, and can be configured with a maximum age after which verification fails.
Because Phoenix.Token is used at trust boundaries — email confirmation links, password reset flows, one-time share links — a failure in token generation or verification breaks authentication paths silently from the user's perspective: the link looks valid but returns an error. High token verification failure rates can indicate replay attacks, clock skew between nodes, or a secret key rotation that invalidated all outstanding tokens.
Vigilmon lets you monitor the liveness of your token pipeline and alert when verification error rates cross dangerous thresholds.
Why Monitor Phoenix.Token?
Token failures surface in your application logs and nowhere else unless you instrument them:
- Secret key rotation — rotating
secret_key_baseimmediately invalidates all outstanding tokens; password-reset and email-confirmation links become unusable until new tokens are generated - Clock skew between nodes —
Phoenix.Token.verify/4computes token age using the node's system clock; if cluster nodes drift, tokens signed on one node may appear expired when verified on another - Max age misconfiguration — setting
max_agetoo low breaks email flows when delivery is slow; too high creates a security window for replay attacks - Salt namespace collision — signing tokens for different use cases with the same salt allows a token from one context (e.g.,
"email_confirm") to be submitted in another (e.g.,"password_reset") - High failure rate indicating abuse — a spike in
{:error, :invalid}returns may indicate credential-stuffing or link-scraping bots submitting tampered tokens - Token generation latency —
Phoenix.Token.sign/4is synchronous and CPU-bound; under load, it can add measurable latency to request paths that generate multiple tokens
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| Token verification success rate | Overall health of token authentication flows |
| {:error, :expired} rate | Max age too low, slow email delivery, or clock skew |
| {:error, :invalid} rate | Tampered tokens, wrong salt, or key rotation fallout |
| Token generation duration | CPU load affecting signing throughput |
| Heartbeat freshness per flow | Whether critical token-gated flows completed recently |
| Active outstanding tokens | Approximate count of unconfirmed invitations or resets |
Step 1: Verify Phoenix.Token Configuration
Phoenix.Token operates on the Phoenix endpoint's secret_key_base. Verify your configuration is explicit and per-environment:
# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
Generate a strong key:
mix phx.gen.secret
For flows with distinct security requirements, always use a unique salt per context:
defmodule MyApp.Tokens do
@email_confirm_salt "email_confirm_v1"
@password_reset_salt "password_reset_v1"
@invite_salt "invite_v1"
# Email confirmation: 48-hour window for slow mail delivery
def sign_email_confirm(conn_or_endpoint, user_id) do
Phoenix.Token.sign(conn_or_endpoint, @email_confirm_salt, user_id)
end
def verify_email_confirm(conn_or_endpoint, token) do
Phoenix.Token.verify(conn_or_endpoint, @email_confirm_salt, token,
max_age: 48 * 60 * 60
)
end
# Password reset: 20-minute window
def sign_password_reset(conn_or_endpoint, user_id) do
Phoenix.Token.sign(conn_or_endpoint, @password_reset_salt, user_id)
end
def verify_password_reset(conn_or_endpoint, token) do
Phoenix.Token.verify(conn_or_endpoint, @password_reset_salt, token,
max_age: 20 * 60
)
end
end
Step 2: Instrument Token Verification with Telemetry
Wrap every verification call to emit structured telemetry:
# lib/my_app/tokens.ex
defmodule MyApp.Tokens do
require Logger
@email_confirm_salt "email_confirm_v1"
@password_reset_salt "password_reset_v1"
def verify_email_confirm(endpoint, token) do
result =
Phoenix.Token.verify(endpoint, @email_confirm_salt, token,
max_age: 48 * 60 * 60
)
emit_verify_event(:email_confirm, result)
result
end
def verify_password_reset(endpoint, token) do
result =
Phoenix.Token.verify(endpoint, @password_reset_salt, token,
max_age: 20 * 60
)
emit_verify_event(:password_reset, result)
result
end
defp emit_verify_event(context, result) do
{outcome, error_reason} =
case result do
{:ok, _} -> {"ok", nil}
{:error, reason} -> {"error", to_string(reason)}
end
:telemetry.execute(
[:my_app, :token, :verify],
%{count: 1},
%{context: context, outcome: outcome, error_reason: error_reason}
)
if outcome == "error" do
Logger.warning("[Token] verify failed",
context: context,
reason: error_reason
)
end
end
end
Attach a handler to count and log these events:
# lib/my_app/application.ex
def start(_type, _args) do
:telemetry.attach(
"my-app-token-handler",
[:my_app, :token, :verify],
&MyApp.TokenTelemetry.handle_event/4,
nil
)
# ... rest of start/2
end
# lib/my_app/token_telemetry.ex
defmodule MyApp.TokenTelemetry do
require Logger
def handle_event([:my_app, :token, :verify], _measurements, meta, _config) do
case meta.outcome do
"ok" ->
Logger.debug("[Token] #{meta.context} verified successfully")
"error" ->
Logger.warning("[Token] #{meta.context} verification failed: #{meta.error_reason}")
end
end
end
Step 3: Build a Token Health Endpoint
Expose a health check that exercises the full sign/verify round-trip:
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
@test_user_id 0
@test_salt "health_check_v1"
def index(conn, _params) do
token_check = check_token_roundtrip(conn)
db_check = check_database()
checks = %{
token: format_check(token_check),
database: format_check(db_check)
}
status = if Enum.all?(checks, fn {_, v} -> v.status == "ok" end), do: 200, else: 503
conn
|> put_status(status)
|> json(%{
status: if(status == 200, do: "ok", else: "degraded"),
checks: checks
})
end
defp check_token_roundtrip(conn) do
start = System.monotonic_time(:millisecond)
token = Phoenix.Token.sign(conn, @test_salt, @test_user_id)
case Phoenix.Token.verify(conn, @test_salt, token, max_age: 60) do
{:ok, @test_user_id} ->
latency_ms = System.monotonic_time(:millisecond) - start
{:ok, latency_ms}
{:ok, _other} ->
{:error, "payload_mismatch"}
{:error, reason} ->
{:error, to_string(reason)}
end
end
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> {:ok, 0}
{:error, reason} -> {:error, inspect(reason)}
end
rescue
e -> {:error, inspect(e)}
end
defp format_check({:ok, latency_ms}), do: %{status: "ok", latency_ms: latency_ms}
defp format_check({:error, reason}), do: %{status: "error", reason: reason}
end
Register the route:
# lib/my_app_web/router.ex
scope "/health", MyAppWeb do
get "/", HealthController, :index
end
Step 4: Heartbeat for Token-Gated Workflows
For critical flows that send token links (registration, password reset), ping a Vigilmon heartbeat on each successful completion:
# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
require Logger
def send_password_reset(email) do
with {:ok, user} <- MyApp.Users.get_by_email(email),
token <- MyApp.Tokens.sign_password_reset(MyAppWeb.Endpoint, user.id),
{:ok, _} <- MyApp.Mailer.send_password_reset(user, token) do
ping_heartbeat(:password_reset)
:ok
end
end
def confirm_email(token) do
case MyApp.Tokens.verify_email_confirm(MyAppWeb.Endpoint, token) do
{:ok, user_id} ->
result = MyApp.Users.mark_confirmed(user_id)
ping_heartbeat(:email_confirm)
result
{:error, reason} ->
{:error, reason}
end
end
defp ping_heartbeat(flow) do
url = Application.get_env(:my_app, :vigilmon_heartbeats, %{})[flow]
if url do
Task.start(fn ->
:httpc.request(:get, {String.to_charlist(url), []}, [{:timeout, 5_000}], [])
end)
end
end
end
Configure heartbeat URLs per environment:
# config/runtime.exs
config :my_app, :vigilmon_heartbeats,
password_reset: System.get_env("VIGILMON_HEARTBEAT_PASSWORD_RESET"),
email_confirm: System.get_env("VIGILMON_HEARTBEAT_EMAIL_CONFIRM")
Step 5: Set Up Monitoring in Vigilmon
HTTP Monitor (sign/verify round-trip)
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Expected status:
200 - Body must contain:
"token":{"status":"ok"} - Check interval: 60 seconds
Heartbeat Monitor (password reset flow)
- Click New Monitor → Heartbeat
- Name:
Password Reset Token Flow - Expected interval: 1 hour (or your expected call frequency)
- Copy the URL → set as
VIGILMON_HEARTBEAT_PASSWORD_RESETin your runtime config
If your password reset flow stops completing — because token signing fails, email delivery breaks, or a bug in the auth controller swallows errors — the heartbeat goes silent and Vigilmon alerts you.
Step 6: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack for health endpoint failure:
🔴 DOWN: Phoenix.Token health check — MyApp
Monitor: /health returned token.status = "error"
Action: Check SECRET_KEY_BASE, Endpoint config, and token salt definitions
PagerDuty for heartbeat miss on password reset:
A missed heartbeat on a password-reset flow means users cannot recover their accounts. Configure a high-priority PagerDuty integration in Vigilmon's notification settings.
Prometheus alert for high token error rate (if using PromEx or a telemetry exporter):
- alert: PhoenixTokenVerifyFailureHigh
expr: |
rate(my_app_token_verify_count{outcome="error"}[5m]) /
rate(my_app_token_verify_count[5m]) > 0.05
for: 3m
annotations:
summary: "Token verification failure rate above 5% — possible key rotation or replay attack"
Common Phoenix.Token Issues and Fixes
Secret key rotation breaks outstanding tokens:
# Use Phoenix.Token.verify/4 with a list of old keys during rotation
# Phoenix does not natively support multi-key verification — maintain two Endpoint configs
# or use a short overlap window where both old and new keys are valid by deploying in stages
Clock skew between nodes:
# Synchronize NTP on all nodes. For Kubernetes, ensure chrony or systemd-timesyncd is running.
# Check skew with: :os.system_time(:second) across nodes via :rpc.call
Salt namespace collision causing token replay:
# Always version your salts and scope them to a single operation
@password_reset_salt "password_reset:v1:#{Application.compile_env(:my_app, :env)}"
Max age too short breaking slow email delivery:
# Set max_age to at least 24 hours for email-based flows;
# for security-sensitive resets, use a database-side used/revoked flag
# rather than relying solely on max_age for one-time enforcement
What You've Built
| What | How |
|------|-----|
| Token round-trip health check | sign then verify in the health endpoint |
| Structured health endpoint | JSON response with token and db checks |
| Telemetry instrumentation | Verify outcome counter with context and error_reason tags |
| Critical flow heartbeats | Vigilmon heartbeat per token-gated workflow |
| HTTP monitor | Vigilmon checks /health every 60 seconds |
| Real-time alerting | Slack for endpoint failure, PagerDuty for heartbeat miss |
Phoenix.Token secures your most sensitive user flows. Vigilmon makes sure those flows keep working when your users need them.
Next Steps
- Add a per-salt failure rate breakdown in your APM dashboard
- Set up Vigilmon's incident history to correlate token failure spikes with deployments or secret rotations
- Monitor email delivery latency alongside token verification to catch slow-delivery-driven expiry
- Use Vigilmon response-time checks on
/healthto detect CPU-bound token signing latency before it affects UX
Get started free at vigilmon.online.