Skip to main content

better_auth_api/plugins/email_verification/
mod.rs

1use chrono::Duration;
2use std::sync::Arc;
3
4use better_auth_core::AuthUser;
5pub use better_auth_core::email::{EmailVerificationHook, SendVerificationEmail};
6use better_auth_core::{AuthContext, AuthError, AuthResult};
7use better_auth_core::{AuthRequest, AuthResponse};
8
9use super::StatusResponse;
10
11pub(super) mod handlers;
12pub(crate) mod token;
13pub(super) mod types;
14
15mod callbacks;
16pub(crate) mod delivery;
17pub use callbacks::{EmailVerificationCallbacks, VerificationEmail};
18
19#[cfg(test)]
20mod tests;
21
22use handlers::*;
23use types::*;
24
25/// Email verification plugin for handling email verification flows
26pub struct EmailVerificationPlugin {
27    config: EmailVerificationConfig,
28}
29
30#[derive(Clone, better_auth_core::PluginConfig)]
31#[plugin(name = "EmailVerificationPlugin")]
32pub struct EmailVerificationConfig {
33    /// How long a verification token stays valid. Default: one hour.
34    #[config(default = None)]
35    pub verification_token_expiry: Option<Duration>,
36    /// Whether sign-in sends through the default email provider. Default: true.
37    /// Custom senders are independent of this setting.
38    #[config(default = true)]
39    pub send_email_notifications: bool,
40    /// Whether email verification is required before sign-in. Default: false.
41    #[config(default = false)]
42    pub require_verification_for_signin: bool,
43    /// When true, automatically send a verification email on sign-in if the
44    /// user is unverified. Default: false.
45    #[config(default = false)]
46    pub send_on_sign_in: bool,
47    /// Send for new unverified OAuth users; defaults to the provider verification requirement.
48    #[config(default = None)]
49    pub send_on_sign_up: Option<bool>,
50    /// When true, create a session after email verification and return the
51    /// session token in the verify-email response. Default: false.
52    #[config(default = false)]
53    pub auto_sign_in_after_verification: bool,
54    /// Optional custom email sender. When set this overrides the default
55    /// `EmailProvider`-based sending.
56    #[config(default = None, skip)]
57    pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
58    /// Hook invoked **before** email verification (before updating the user).
59    #[config(default = None)]
60    pub before_email_verification: Option<EmailVerificationHook>,
61    /// Hook invoked **after** email verification (after the user has been updated).
62    #[config(default = None)]
63    pub after_email_verification: Option<EmailVerificationHook>,
64}
65
66impl EmailVerificationConfig {
67    /// Read the verification-token lifetime. Omission uses one hour.
68    pub fn verification_token_expiry(&self) -> Duration {
69        self.verification_token_expiry
70            .unwrap_or_else(|| Duration::hours(1))
71    }
72}
73
74impl EmailVerificationPlugin {
75    pub fn custom_send_verification_email(
76        mut self,
77        sender: Arc<dyn SendVerificationEmail>,
78    ) -> Self {
79        self.config.send_verification_email = Some(sender);
80        self
81    }
82}
83
84better_auth_core::impl_auth_plugin! {
85    EmailVerificationPlugin, "email-verification";
86    routes {
87        post "/send-verification-email" => handle_send_verification_email, "sendVerificationEmail", body = types::body;
88        get "/verify-email" => handle_verify_email, "verifyEmail", query = crate::plugins::query_input::verify_email;
89    }
90    extra {
91        fn telemetry_plugin_id(&self) -> Option<&'static str> {
92            None
93        }
94
95        fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
96            let options = &mut options.email_verification;
97            options.expires_in = self.config.verification_token_expiry;
98            options.send_verification_email = self.config.send_verification_email.is_some();
99            options.send_on_sign_up = self.config.send_on_sign_up == Some(true);
100            options.send_on_sign_in = self.config.send_on_sign_in;
101            options.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
102            options.before_email_verification = self.config.before_email_verification.is_some();
103            options.after_email_verification = self.config.after_email_verification.is_some();
104        }
105
106        async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
107            ctx.extensions.insert(self.config.clone());
108            ctx.email_verification_policy.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
109            ctx.email_verification_policy.before_email_verification = self.config.before_email_verification.clone();
110            ctx.email_verification_policy.after_email_verification = self.config.after_email_verification.clone();
111            Ok(())
112        }
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Route handlers (delegate to core functions)
118// ---------------------------------------------------------------------------
119
120impl EmailVerificationPlugin {
121    pub(crate) fn from_context(
122        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
123    ) -> Option<Self> {
124        ctx.extensions
125            .get::<EmailVerificationConfig>()
126            .map(|config| Self::with_config(config.clone()))
127    }
128
129    pub(crate) async fn send_verification_on_sign_up<S: better_auth_core::AuthSchema>(
130        &self,
131        user: &impl AuthUser,
132        required: bool,
133        callback_url: Option<&str>,
134        endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
135    ) -> AuthResult<()> {
136        if self.config.send_on_sign_up.unwrap_or(required)
137            && let Some(email) = user.email()
138        {
139            self.send_verification_email_at(user, email, callback_url, endpoint)
140                .await?;
141        }
142        Ok(())
143    }
144
145    async fn handle_send_verification_email(
146        &self,
147        req: &AuthRequest,
148        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
149    ) -> AuthResult<AuthResponse> {
150        let body = match req.validated_body::<SendVerificationEmailRequest>() {
151            Some(body) => body.clone(),
152            None => super::json_body::email_input(req, "email", "callbackURL")?.0,
153        };
154        let current_session = ctx.require_session(req).await.ok();
155        let mut config = self.config.clone();
156        if config.send_verification_email.is_none() {
157            config.send_verification_email = ctx.email_verification_policy.override_sender.clone();
158        }
159        let response =
160            send_verification_email_core(&body, current_session.as_ref(), Some(req), &config, ctx)
161                .await?;
162        Ok(AuthResponse::json(200, &response)?)
163    }
164
165    async fn handle_verify_email(
166        &self,
167        req: &AuthRequest,
168        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
169    ) -> AuthResult<AuthResponse> {
170        let token = req
171            .query_string("token")?
172            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
173        let callback_url = req
174            .query_string("callbackURL")?
175            .filter(|url| !url.is_empty())
176            .map(str::to_owned);
177
178        // Validate callbackURL against trusted origins, matching the TS
179        // `originCheck` middleware applied to the verify-email endpoint.
180        if let Some(ref url) = callback_url
181            && !ctx.config.advanced.disable_origin_check
182            && !ctx.is_redirect_target_trusted(url)
183        {
184            return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
185        }
186
187        let query = VerifyEmailQuery {
188            token: token.to_owned(),
189            callback_url,
190        };
191
192        let ip_address = ctx.config.advanced.ip_address.resolve(req);
193        let user_agent = req.headers.get("user-agent").cloned();
194        let current_session = ctx.require_session(req).await.ok();
195        let mut endpoint = crate::plugins::endpoint_context::EndpointContext::new(
196            Some(req),
197            better_auth_core::FieldValue::Null,
198            ctx,
199        );
200        endpoint.session = current_session.clone();
201
202        match verify_email_core(
203            &query,
204            current_session,
205            &self.config,
206            ip_address,
207            user_agent,
208            &endpoint,
209        )
210        .await?
211        {
212            VerifyEmailResult::Redirect { url, session_data } => {
213                let mut headers = better_auth_core::Headers::new();
214                _ = headers.insert("Location".to_string(), url);
215                _ = headers.insert("content-type".to_string(), "application/json".to_string());
216                if let Some(data) = session_data {
217                    ctx.session_manager()
218                        .set_session_cookie(req, data, None)
219                        .await?;
220                }
221                let mut response = AuthResponse::new(302);
222                response.headers = headers;
223                Ok(response)
224            }
225            VerifyEmailResult::Json { body, session_data } => {
226                let response = AuthResponse::json(200, &body)?;
227                if let Some(data) = session_data {
228                    ctx.session_manager()
229                        .set_session_cookie(req, data, None)
230                        .await?;
231                }
232                Ok(response)
233            }
234        }
235    }
236
237    /// Send a verification email for a specific user.
238    ///
239    /// If [`EmailVerificationConfig::send_verification_email`] is set the
240    /// custom callback is used; otherwise the default `EmailProvider` path is
241    /// taken.
242    async fn send_verification_email_for_user(
243        &self,
244        user: &impl AuthUser,
245        email: &str,
246        callback_url: Option<&str>,
247        request: Option<&AuthRequest>,
248        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
249    ) -> AuthResult<()> {
250        let body = request
251            .filter(|request| request.body.is_some())
252            .map(|request| request.body_as_json())
253            .transpose()?
254            .unwrap_or(serde_json::Value::Null);
255        let endpoint = crate::plugins::endpoint_context::EndpointContext::new(
256            request,
257            better_auth_core::FieldValue::from_json(body)?,
258            ctx,
259        );
260        self.send_verification_email_at(user, email, callback_url, &endpoint)
261            .await
262    }
263
264    async fn send_verification_email_at<S: better_auth_core::AuthSchema>(
265        &self,
266        user: &impl AuthUser,
267        email: &str,
268        callback_url: Option<&str>,
269        endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
270    ) -> AuthResult<()> {
271        let ctx = endpoint.auth;
272        let verification_token = token::create_email_verification_token(
273            ctx.config.signing_secret(),
274            email,
275            None,
276            self.config.verification_token_expiry(),
277            None,
278        )?;
279        let callback_url = callback_url.unwrap_or("/");
280        let verification_url = format!(
281            "{}/verify-email?token={}&callbackURL={}",
282            ctx.base_url(),
283            verification_token,
284            urlencoding::encode(callback_url),
285        );
286
287        if ctx
288            .extensions
289            .get::<Arc<EmailVerificationCallbacks<S>>>()
290            .is_none()
291            && self.config.send_verification_email.is_none()
292            && crate::plugins::email_otp::callbacks::overrides_verification(ctx)
293        {
294            let retained = endpoint.to_owned();
295            let email = email.to_owned();
296            better_auth_core::background::run_or_await(
297                Some(Box::pin(async move {
298                    let endpoint = retained.as_endpoint();
299                    crate::plugins::email_otp::callbacks::send_verification_override(
300                        &email, &endpoint,
301                    )
302                    .await
303                })),
304                ctx.config.advanced.background_tasks.as_ref(),
305                &ctx.config.logger,
306            )
307            .await;
308            return Ok(());
309        }
310        let message = VerificationEmail {
311            user: ctx.internal_user_view(user).await?,
312            url: verification_url,
313            token: verification_token,
314        };
315        let task = delivery::delivery(Some(&self.config), message, endpoint)?;
316        better_auth_core::background::run_or_await(
317            task,
318            ctx.config.advanced.background_tasks.as_ref(),
319            &ctx.config.logger,
320        )
321        .await;
322
323        Ok(())
324    }
325
326    /// Send a verification email on sign-in for an unverified user.
327    ///
328    /// Callers (e.g. the sign-in plugin) should invoke this when
329    /// [`EmailVerificationConfig::send_on_sign_in`] is `true` and the user is
330    /// not yet verified.
331    pub async fn send_verification_on_sign_in(
332        &self,
333        user: &impl AuthUser,
334        callback_url: Option<&str>,
335        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
336    ) -> AuthResult<()> {
337        self.send_verification_on_sign_in_with_request(user, callback_url, None, ctx)
338            .await
339    }
340
341    pub(crate) async fn send_verification_on_sign_in_with_request(
342        &self,
343        user: &impl AuthUser,
344        callback_url: Option<&str>,
345        request: Option<&AuthRequest>,
346        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
347    ) -> AuthResult<()> {
348        if !self.config.send_on_sign_in {
349            return Ok(());
350        }
351
352        if user.email_verified() {
353            return Ok(());
354        }
355
356        if let Some(email) = user.email() {
357            self.send_verification_email_for_user(user, email, callback_url, request, ctx)
358                .await?;
359        }
360
361        Ok(())
362    }
363
364    pub(crate) async fn send_verification_on_oauth_sign_in(
365        &self,
366        user: Option<&impl AuthUser>,
367        is_register: bool,
368        require_verification: bool,
369        callback_url: &str,
370        request: &AuthRequest,
371        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
372    ) {
373        let should_send = if is_register {
374            self.config.send_on_sign_up.unwrap_or(require_verification)
375        } else {
376            require_verification && self.config.send_on_sign_in
377        };
378        if !should_send
379            || user.is_some_and(AuthUser::email_verified)
380            || !delivery::available(Some(&self.config), ctx)
381        {
382            return;
383        }
384        let result = async {
385            let (user, email) = user
386                .and_then(|user| user.email().map(|email| (user, email)))
387                .ok_or_else(|| {
388                    AuthError::internal(
389                        "Cannot create an OAuth verification token without a user email",
390                    )
391                })?;
392            self.send_verification_email_for_user(
393                user,
394                email,
395                Some(callback_url),
396                Some(request),
397                ctx,
398            )
399            .await
400        }
401        .await;
402        if let Err(error) = result {
403            // Upstream logs verification delivery failures without changing the OAuth verification decision.
404            better_auth_core::observability::logger::current().error(
405                "Failed to send OAuth verification email",
406                &[better_auth_core::observability::LogArgument::Error(&error)],
407            );
408        }
409    }
410
411    /// Check if `send_on_sign_in` is enabled.
412    pub fn should_send_on_sign_in(&self) -> bool {
413        self.config.send_on_sign_in
414    }
415
416    /// Check if email verification is required for signin
417    pub fn is_verification_required(&self) -> bool {
418        self.config.require_verification_for_signin
419    }
420
421    /// Check if user is verified or verification is not required
422    pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
423        user.email_verified() || !self.config.require_verification_for_signin
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Axum plugin
429// ---------------------------------------------------------------------------