tutorial

How to Monitor Phoenix.Token with Vigilmon

Track Phoenix.Token signing health, token verification failure rates, and expiry-driven auth degradation in your Elixir application, and use Vigilmon to alert before stale tokens lock users out.

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_base immediately invalidates all outstanding tokens; password-reset and email-confirmation links become unusable until new tokens are generated
  • Clock skew between nodesPhoenix.Token.verify/4 computes 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_age too 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 latencyPhoenix.Token.sign/4 is 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)

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Set URL to https://your-app.example.com/health
  4. Expected status: 200
  5. Body must contain: "token":{"status":"ok"}
  6. Check interval: 60 seconds

Heartbeat Monitor (password reset flow)

  1. Click New Monitor → Heartbeat
  2. Name: Password Reset Token Flow
  3. Expected interval: 1 hour (or your expected call frequency)
  4. Copy the URL → set as VIGILMON_HEARTBEAT_PASSWORD_RESET in 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 /health to detect CPU-bound token signing latency before it affects UX

Get started free at vigilmon.online.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →