Ash Authentication has flawed token revocation checking logic in actions generated by `mix ash_authentication.install`
Description
Revoked tokens in Ash Authentication for Elixir are allowed to verify as valid when using installer-generated actions, enabling reuse of magic link, password reset, and confirmation tokens until their expiration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Revoked tokens in Ash Authentication for Elixir are allowed to verify as valid when using installer-generated actions, enabling reuse of magic link, password reset, and confirmation tokens until their expiration.
CVE-2025-25202 affects Ash Authentication, an authentication framework for Elixir applications. The vulnerability arises from a flaw in the token revocation checking logic in actions generated by the mix ash_authentication.install igniter installer present since AshAuthentication v4.1.0 [1][2][4]. When a custom :revoked? generic action is generated by the installer, it fails to properly enforce revocation, allowing revoked tokens to be accepted as valid [2][3][4]. The internal :revoked? action within Ash Authentication itself is not affected and continues to work correctly [2].
Exploitation requires that an application was bootstrapped using the new igniter installer and uses the magic link strategy, password resets, confirmation add-on, or manually revokes tokens [2][4]. An attacker who obtains a valid token (for example, a magic link sent to a user) can reuse that token multiple times until it expires, because revocation is not enforced [4]. Magic link tokens have a default lifetime of 10 minutes, while password reset and confirmation tokens default to 3 days [4]. No authentication is needed beyond possession of the token itself.
The impact is that tokens intended for single-use (like magic links) can be reused, potentially allowing an attacker to authenticate multiple times or complete actions such as password resets with a previously used token [2][4]. The surface area is considered low for magic links due to the short expiration, but password reset and confirmation tokens have longer lifetimes [4]. The flaw is patched in version 4.4.9, which includes an upgrader and compile-time warnings with remediation instructions [2][3][4]. As a workaround, administrators can delete the generated :revoked? generic action in the token resource, reverting to the correct internal implementation [2].
Mitigation
Upgrade to Ash Authentication version 4.4.9 or later [2][4]. The patch includes an automatic upgrader via mix igniter.upgrade ash_authentication, or the upgrade can be run manually as described in error messages [2][3]. The fix ensures that the token revocation logic correctly validates the revoked status before allowing token use.
- GitHub - team-alembic/ash_authentication: The Ash Authentication framework
- NVD - CVE-2025-25202
- fix: Ensure that installer generated token revocation checking action… · team-alembic/ash_authentication@2dee552
- Flawed token revocation checking logic in actions generated by `mix ash_authentication.install`
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
ash_authenticationHex | >= 4.1.0, < 4.4.9 | 4.4.9 |
Affected products
2- Range: >= 4.1.0, < 4.4.9
Patches
12dee55252df2fix: Ensure that installer generated token revocation checking action is correct. (#905)
9 files changed · +276 −33
lib/ash_authentication/add_ons/confirmation/transformer.ex+1 −1 modified@@ -22,7 +22,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do with :ok <- validate_token_generation_enabled( dsl_state, - "Token generation must be enabled for password resets to work." + "Token generation must be enabled for confirmation to work." ), :ok <- validate_monitor_fields(dsl_state, strategy), strategy <- maybe_set_confirm_action_name(strategy),
lib/ash_authentication/token_resource.ex+2 −1 modified@@ -145,7 +145,8 @@ defmodule AshAuthentication.TokenResource do use Spark.Dsl.Extension, sections: @dsl, - transformers: [TokenResource.Transformer, TokenResource.Verifier] + transformers: [TokenResource.Transformer], + verifiers: [TokenResource.Verifier] @doc """ Has the token been revoked?
lib/ash_authentication/token_resource/is_revoked.ex+25 −4 modified@@ -1,13 +1,34 @@ defmodule AshAuthentication.TokenResource.IsRevoked do @moduledoc """ - Checks for the existence of a revocation token for the provided tokenrevocation token for the provided token. + Checks for the existence of a revocation token for the provided token revocation token for the provided token. """ use Ash.Resource.Actions.Implementation + alias Ash.Error.Action.InvalidArgument + alias AshAuthentication.{Errors.InvalidToken, Jwt} @impl true - def run(input, _, _) do - input.resource - |> Ash.Query.do_filter(purpose: "revocation", jti: input.arguments.jti) + def run(%{resource: resource, arguments: %{jti: jti}}, _, _) when is_binary(jti) do + resource + |> Ash.Query.do_filter(purpose: "revocation", jti: jti) + |> Ash.Query.set_context(%{ + private: %{ash_authentication?: true} + }) |> Ash.exists() end + + def run(%{arguments: %{token: token}} = input, opts, context) when is_binary(token) do + case Jwt.peek(token) do + {:ok, %{"jti" => jti}} -> run(%{input | arguments: %{jti: jti}}, opts, context) + {:ok, _} -> {:error, InvalidToken.exception(type: :revocation)} + {:error, reason} -> {:error, reason} + end + end + + def run(_input, _, _) do + {:error, + InvalidArgument.exception( + field: :jti, + message: "At least one of `jti` or `token` arguments must be present" + )} + end end
lib/ash_authentication/token_resource/verifier.ex+59 −20 modified@@ -3,40 +3,79 @@ defmodule AshAuthentication.TokenResource.Verifier do The token resource verifier. """ - use Spark.Dsl.Transformer + use Spark.Dsl.Verifier require Ash.Expr - alias Spark.{Dsl.Transformer, Error.DslError} + require Logger + alias Spark.{Dsl.Verifier, Error.DslError} import AshAuthentication.Utils + import AshAuthentication.Validations.Action @doc false @impl true - @spec after?(any) :: boolean() - def after?(_), do: true + @spec verify(map) :: :ok | {:error, term} + def verify(dsl_state) do + with :ok <- validate_domain_presence(dsl_state) do + maybe_validate_is_revoked_action_arguments(dsl_state) + end + end - @doc false - @impl true - @spec before?(any) :: boolean - def before?(_), do: false + defp maybe_validate_is_revoked_action_arguments(dsl_state) do + case Verifier.get_option(dsl_state, [:token, :revocation], :is_revoked_action_name, :revoked?) do + nil -> + :ok - @doc false - @impl true - @spec after_compile? :: boolean - def after_compile?, do: true + action_name -> + case validate_action_exists(dsl_state, action_name) do + {:ok, action} -> validate_is_revoked_action(dsl_state, action) + {:error, _} -> :ok + end + end + end - @doc false - @impl true - @spec transform(map) :: - :ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt - def transform(dsl_state) do - validate_domain_presence(dsl_state) + defp validate_is_revoked_action(dsl_state, action) do + with :ok <- validate_action_argument_option(action, :jti, :allow_nil?, [true]), + :ok <- validate_action_argument_option(action, :token, :allow_nil?, [true]), + :ok <- validate_action_option(action, :returns, [:boolean, Ash.Type.Boolean]) do + :ok + else + {:error, _} -> + Logger.warning(""" + Warning while compiling #{inspect(Verifier.get_persisted(dsl_state, :module))}: + + The `:jti` and `:token` options to the `#{inspect(action.name)}` action must allow nil values and it must return a `:boolean`. + + This was an error in our igniter installer previous to version 4.4.9, which allowed revoked tokens to be reused. + + To fix this, run the following command in your shell: + + mix ash_authentication.upgrade 4.4.8 4.4.9 + + Or: + + - remove `allow_nil?: false` from these action arguments, and + - ensure that the action returns `:boolean`. + + like so: + + action :revoked?, :boolean do + description "Returns true if a revocation token is found for the provided token" + argument :token, :string, sensitive?: true + argument :jti, :string, sensitive?: true + + run AshAuthentication.TokenResource.IsRevoked + end + """) + + :ok + end end defp validate_domain_presence(dsl_state) do - with domain when not is_nil(domain) <- Transformer.get_option(dsl_state, [:token], :domain), + with domain when not is_nil(domain) <- Verifier.get_option(dsl_state, [:token], :domain), :ok <- assert_is_module(domain), true <- function_exported?(domain, :spark_is, 0), Ash.Domain <- domain.spark_is() do - {:ok, domain} + :ok else nil -> {:error,
lib/mix/tasks/ash_authentication.install.ex+3 −3 modified@@ -342,10 +342,10 @@ if Code.ensure_loaded?(Igniter) do end """) |> Ash.Resource.Igniter.add_action(token_resource, """ - action :revoked? do + action :revoked?, :boolean do description "Returns true if a revocation token is found for the provided token" - argument :token, :string, sensitive?: true, allow_nil?: false - argument :jti, :string, sensitive?: true, allow_nil?: false + argument :token, :string, sensitive?: true + argument :jti, :string, sensitive?: true run AshAuthentication.TokenResource.IsRevoked end
lib/mix/tasks/ash_authentication.upgrade.ex+172 −0 added@@ -0,0 +1,172 @@ +# credo:disable-for-this-file Credo.Check.Design.AliasUsage +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.AshAuthentication.Upgrade do + @moduledoc false + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + # Groups allow for overlapping arguments for tasks by the same author + # See the generators guide for more. + group: :ash_authentication, + # *other* dependencies to add + # i.e `{:foo, "~> 2.0"}` + adds_deps: [], + # *other* dependencies to add and call their associated installers, if they exist + # i.e `{:foo, "~> 2.0"}` + installs: [], + # An example invocation + # example: __MODULE__.Docs.example(), + example: "example", + # a list of positional arguments, i.e `[:file]` + positional: [:from, :to], + # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv + # This ensures your option schema includes options from nested tasks + composes: [], + # `OptionParser` schema + schema: [], + # Default values for the options in the `schema` + defaults: [], + # CLI aliases + aliases: [], + # A list of options in the schema that are required + required: [] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + positional = igniter.args.positional + options = igniter.args.options + + upgrades = + %{ + "4.4.9" => [&fix_token_is_revoked_action/2] + } + + # For each version that requires a change, add it to this map + # Each key is a version that points at a list of functions that take an + # igniter and options (i.e. flags or other custom options). + # See the upgrades guide for more. + Igniter.Upgrades.run(igniter, positional.from, positional.to, upgrades, + custom_opts: options + ) + end + + def fix_token_is_revoked_action(igniter, _opts) do + case find_all_token_resources(igniter) do + {igniter, []} -> + igniter + + {igniter, resources} -> + Enum.reduce(resources, igniter, fn resource, igniter -> + maybe_fix_is_revoked_action(igniter, resource) + end) + end + end + + defp find_all_token_resources(igniter) do + Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper -> + with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), + {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :extensions) do + if Igniter.Code.List.list?(zipper) do + match?( + {:ok, _}, + Igniter.Code.List.move_to_list_item( + zipper, + &Igniter.Code.Common.nodes_equal?(&1, AshAuthentication.TokenResource) + ) + ) + else + Igniter.Code.Common.nodes_equal?(zipper, AshAuthentication.TokenResource) + end + end + end) + end + + defp maybe_fix_is_revoked_action(igniter, resource) do + Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> + with {:ok, action_zipper} <- move_to_action(zipper, :action, :revoked?), + {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper), + {:ok, zipper} <- remove_argument_option(zipper, :token, :allow_nil?), + {:ok, zipper} <- remove_argument_option(zipper, :jti, :allow_nil?) do + add_action_return_type(zipper, :boolean) + else + :error -> {:ok, zipper} + end + end) + end + + defp move_to_action(zipper, type, name) do + Igniter.Code.Function.move_to_function_call( + zipper, + type, + 2, + &Igniter.Code.Function.argument_equals?(&1, 0, name) + ) + end + + defp add_action_return_type(zipper, type) do + zipper = Sourceror.Zipper.top(zipper) + + with {:ok, zipper} <- move_to_action(zipper, :action, :revoked?), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do + {:ok, + Sourceror.Zipper.insert_left( + zipper, + quote do + unquote(type) + end + )} + end + end + + defp remove_argument_option(zipper, argument_name, option) do + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call( + zipper, + :argument, + 3, + &Igniter.Code.Function.argument_equals?(&1, 0, argument_name) + ), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 2) do + if Igniter.Code.List.find_list_item_index( + zipper, + &Igniter.Code.Tuple.elem_equals?(&1, 0, :do) + ) do + Igniter.Code.Common.within(zipper, fn zipper -> + {:ok, + Igniter.Code.Common.remove( + zipper, + &Igniter.Code.Function.function_call?(&1, option, 1) + )} + end) + else + Igniter.Code.List.remove_from_list( + zipper, + &Igniter.Code.Tuple.elem_equals?(&1, 0, option) + ) + end + end + end + end +else + defmodule Mix.Tasks.AshAuthentication.Upgrade do + @moduledoc false + + use Mix.Task + + def run(_argv) do + Mix.shell().error(""" + The task 'ash_authentication.upgrade' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end
test/mix/tasks/ash_authentication.install_test.exs+3 −3 modified@@ -189,10 +189,10 @@ defmodule Mix.Tasks.AshAuthentication.InstallTest do prepare(AshAuthentication.TokenResource.GetTokenPreparation) end - action :revoked? do + action :revoked?, :boolean do description("Returns true if a revocation token is found for the provided token") - argument(:token, :string, sensitive?: true, allow_nil?: false) - argument(:jti, :string, sensitive?: true, allow_nil?: false) + argument(:token, :string, sensitive?: true) + argument(:jti, :string, sensitive?: true) run(AshAuthentication.TokenResource.IsRevoked) end
test/support/example/token.ex+9 −0 modified@@ -12,5 +12,14 @@ defmodule Example.Token do actions do defaults [:read, :destroy] + + action :revoked? do + description "Returns true if a revocation token is found for the provided token" + argument :token, :string, sensitive?: true + argument :jti, :string, sensitive?: true + + run AshAuthentication.TokenResource.IsRevoked + returns :boolean + end end end
.vscode/settings.json+2 −1 modified@@ -7,6 +7,7 @@ "Marties", "moduledocs", "oidc", - "unguessable" + "unguessable", + "zipper" ] }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-qrm9-f75w-hg4cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-25202ghsaADVISORY
- github.com/team-alembic/ash_authentication/commit/2dee55252df26fe3d990ff1199397cdcf1bfea8aghsax_refsource_MISCWEB
- github.com/team-alembic/ash_authentication/security/advisories/GHSA-qrm9-f75w-hg4cghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.