# Account Security Hooks

https://dev.wisecp.com/es/account-security-hooks

The nine hooks over verification, password resets, sign-outs and access.

## Overview

Every step that guards the identity of an account lives here: email and phone verification, the security question, password resets, login codes and sign-outs.

Two gates stand out. The password gate hands you the **raw password**; only check it. The access gate works the other way round from the rest: a filled return **skips** the built-in checks.

## Reference

### Following an email change

actionuser.email_changed

`AccountUsers` a verified address

Runs after the email address of an account changes. The new address is **verified**.

Parameters 3

$uidintThe account owner.

$oldEmailstringThe previous address.

$newEmailstringThe new, verified address.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.email_changed', 10, function ($uid, $oldEmail, $newEmail) {
    // Tell the old address too: if the account was taken, that is the only warning path.
    Acme::alertOldAddress($oldEmail, $newEmail);
});
```

### Following an email verification

actionuser.email_verified

`AccountUsers` at verification

Runs after a customer verifies their email address.

Parameters 1

$user_idintThe customer whose address was verified.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.email_verified', 10, function ($user_id) {
    Acme::unlockOnboarding($user_id);
});
```

### Following a phone verification

actionuser.phone_verified

`AccountUsers` international form

Runs after a customer verifies their phone number.

Parameters 2

$uidintThe customer whose number was verified.

$phonestringThe verified number as stored, in **international form**. The country code leads it; do not expect a local format.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.phone_verified', 10, function ($uid, $phone) {
    // The number is in international form.
    Acme::enableSmsAlerts($uid, $phone);
});
```

### Following a security question change

actionuser.security_question_changed

`AccountUsers` always the login id

Runs when the security question of an account is set or removed.

Parameters 2

$user_idintThe member whose question changed. This is **always the person signed in**: a sub-user cannot change somebody else’s question.

$has_questionboolTrue when a question is now set; false when the question **and its answer** were removed together.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.security_question_changed', 10, function ($user_id, $has_question) {
    // Removal comes through this hook too.
    if (!$has_question) Acme::warnWeakerRecovery($user_id);
});
```

### Following a password reset request

actionuser.password_reset_requested

`Auth` two account types

Runs when a password reset is requested. Both customer and administrator accounts pass here.

Parameters 3

$userIdintThe account the request opened for.

$typestringThe account type: `member` or `admin`. Administrator resets matter far more; keep the two apart.

$emailstringThe address it was requested from, confirmed to match the account.

Return 1

voidThe return is ignored. What your listener returns **cannot change** the reset response.

Listener PHP

```php
Hook::add('action:user.password_reset_requested', 10, function ($userId, $type, $email) {
    // An administrator reset matters far more.
    if ($type === 'admin') Acme::alertSecurityTeam($userId, $email);
});
```

### Following a sign-out

actionuser.logged_out

`Auth` session captured first

Runs when a session is closed.

Parameters 2

$dataarrayThe session of the person leaving, **captured before the sign-out**. With no session it **arrives empty**: look before reaching for an id.

$typestringThe sign-out type: `admin` or `member`.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.logged_out', 10, function ($data, $type) {
    // With no session the array arrives EMPTY.
    if (!($data['id'] ?? 0)) return;

    Acme::closeSession((int) $data['id'], $type);
});
```

### Following a login code being sent

actionuser.login_code_issued

`Auth` the code is not passed

Runs when a one-time login code goes out.

Parameters 3

$userIdintThe member the code went to.

$channelstringThe delivery channel.

$expiryintWhen the code stops working. The code **itself is not passed**, deliberately: handing it to a listener would spread it to a second place.

Return 1

voidThe return is ignored; it cannot change the response.

Listener PHP

```php
Hook::add('action:user.login_code_issued', 10, function ($userId, $channel, $expiry) {
    // The code itself is deliberately withheld.
    Acme::noteLoginAttempt($userId, $channel);
});
```

### Following a device being trusted

actionuser.device_trusted

`Auth` the trust token is not passed

Runs when an account ticks "trust this device" on the two-factor screen and the browser is remembered.

Parameters 4

$userIdintThe account the device now belongs to.

$typestringThe account type: `member` or `admin`.

$windowintHow many days the trust lasts.

$uastringThe browser's raw User-Agent string. The trust token **is not passed**: handing it to a listener would spread it to a second place.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:user.device_trusted', 10, function ($userId, $type, $window, $ua) {
    Acme::securityLog('device_trusted', $userId, ['type' => $type, 'days' => $window]);
});
```

### Following device trust being revoked

actionuser.device_trust_revoked

`Auth` rows are already gone

Runs when an account's trusted devices are removed: a password reset, two-factor being switched on or off, a recovery-code sign-in, "sign out everywhere", or a removal from the account's own list.

Parameters 3

$userIdintThe account whose trust was revoked.

$typestringThe account type: `member` or `admin`. On a bulk clear with no type given it **arrives empty** — every type went.

$countintHow many devices were removed; a single removal always passes `1`.

Return 1

voidThe return is ignored; the rows are already deleted when it runs.

Listener PHP

```php
Hook::add('action:user.device_trust_revoked', 10, function ($userId, $type, $count) {
    Acme::securityLog('trust_revoked', $userId, ['count' => $count]);
});
```

### Stopping a password reset

gateuser.password_reset

`AdminUsers` the raw password

Runs before an administrator password is reset. Enforce your own password policy here.

Parameters 3

$user_idintThe account being reset.

$passwordstringThe new password **in the clear**. ? For a policy check only: do not log it, store it or send it anywhere.

$bystringWhere the reset came from.

Return 1

mixed**A filled return blocks it**: either a text or an array carrying a message. An empty return lets it carry on.

Listener PHP

```php
Hook::add('gate:user.password_reset', 10, function ($user_id, $password, $by) {
    // The raw password: check it only, write it NOWHERE.
    if (strlen($password) < 16) return 'An administrator password needs at least 16 characters.';

    return null;
});
```

### Sending a customer to a screen of your own

gateuser.full_access

`website` returns a redirect

Runs when a customer enters the panel, **ahead of** the built-in access checks: missing fields, data consent and billing details all come after it.

Parameters 1

$idintThe customer signing in.

Return 1

string|null? It works the other way round from other gates: **returning a filled address** sends the customer there and **skips every built-in check**. Returning an address unconditionally switches off the missing-field and consent checks for good. Keep your condition narrow and return empty otherwise.

Listener PHP

```php
Hook::add('gate:user.full_access', 10, function ($id) {
    // A filled return SKIPS THE BUILT-IN CHECKS: keep the condition narrow.
    if (Acme::mustAcceptTerms($id)) return Utility::AppAdress() . '/acme/terms';

    return null;
});
```

## Pitfalls

> **The access gate works the other way round**
> 
> In other gates a filled return **blocks**; in the access gate a filled return **redirects and skips every built-in check**. Returning an address unconditionally switches off the missing-field and consent checks for good.

> **The password gate receives the raw password**
> 
> The reset gate hands you the new password **in the clear**, which a policy check needs. Logging it, storing it or sending it anywhere leaks exactly what you set out to protect.

## Related Articles

- [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks)
- [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks)
- [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work)
