With this Symfony bundle you can send an email alert when a user logs in from a new context — for example:
- a different IP address
- a different location (geolocation)
- a different User Agent (device/browser)
This helps detect unusual login activity early and increases visibility into authentication events.
📖 Read the full documentation — installation, configuration, geolocation providers, async processing, events and customization.
Upgrading from v1? See the UPGRADE.md guide for a step-by-step migration.
To ensure strong authentication security, this bundle aligns with guidance from the OWASP Authentication Cheat Sheet by:
- Treating authentication failures or unusual logins as events worthy of detection and alerting
- Ensuring all login events are logged, especially when the context changes (IP, location, device)
- Using secure channels (TLS) for all authentication-related operations — the optional
ipApigeolocation provider is the one exception, and is documented as development-only - Validating and normalizing incoming data (e.g. user agent strings, IP addresses) to avoid ambiguity or spoofing
- Authentication Event Logging: Track successful logins with IP, user agent, timestamp and location
- Geolocation Support: Enrich logs with location data using a local GeoIP2 database (recommended) or the IP API service
- Email Notifications: Automatically alert users when a login from an unknown context is detected
- Messenger Integration: Optional async processing with Symfony Messenger
- Repository-Based Persistence: No factory or listener boilerplate — implement two interfaces in your repository and you're done
- Extensible: Replace the default email notification with any custom transport via
NotificationInterface
composer require spiriitlabs/auth-log-bundle# config/packages/spiriit_auth_log.yaml
spiriit_auth_log:
transports:
sender_email: 'no-reply@yourdomain.com'
sender_name: 'Security'AuthLogUserInterface extends UserInterface, so you no longer need to declare it explicitly.
use Spiriit\Bundle\AuthLogBundle\Entity\AuthLogUserInterface;
class User implements AuthLogUserInterface
{
// ... your existing User fields
public function getAuthLogEmail(): string
{
return $this->email;
}
public function getAuthLogDisplayName(): string
{
return $this->name;
}
}Extend AbstractAuthenticationLog and add a relation to your User entity:
use Doctrine\ORM\Mapping as ORM;
use Spiriit\Bundle\AuthLogBundle\DTO\UserIdentity;
use Spiriit\Bundle\AuthLogBundle\Entity\AbstractAuthenticationLog;
use Spiriit\Bundle\AuthLogBundle\Entity\AuthLogUserInterface;
use Spiriit\Bundle\AuthLogBundle\FetchUserInformation\UserInformation;
#[ORM\Entity(repositoryClass: UserAuthLogRepository::class)]
#[ORM\Index(columns: ['user_identifier', 'user_class', 'ip_address'])]
class UserAuthLog extends AbstractAuthenticationLog
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;
public function __construct(User $user, UserIdentity $userIdentity, UserInformation $userInformation)
{
$this->user = $user;
parent::__construct($userIdentity, $userInformation);
}
public function getUser(): AuthLogUserInterface
{
return $this->user;
}
}Several user classes? The example above relates the log to a single
Userentity, so it can only store logs for that class. If several firewalls with distinct user classes share one log table, declare one nullable relation per class and havegetUser()return whichever is set (getUser()must always return anAuthLogUserInterface). Theuser_classcolumn then tells them apart infindExistingLog()— that is what it is for. Keeping one log table per user class works too, and is simpler.
Your repository must implement two interfaces:
AuthenticationLogRepositoryInterface— check if a log already exists and save new logsAuthenticationLogCreatorInterface— build the log entity from a user identity and user information
A UserIdentity carries both the user identifier and the user class (FQCN). Keep the class in the lookup: two accounts of different classes may share the same identifier. The log stores both, so findExistingLog() needs no join to the User entity — only createLog() does.
use Doctrine\ORM\EntityRepository;
use Spiriit\Bundle\AuthLogBundle\AuthenticationLog\AuthenticationLogCreatorInterface;
use Spiriit\Bundle\AuthLogBundle\DTO\UserIdentity;
use Spiriit\Bundle\AuthLogBundle\Entity\AuthenticationLogInterface;
use Spiriit\Bundle\AuthLogBundle\FetchUserInformation\UserInformation;
use Spiriit\Bundle\AuthLogBundle\Repository\AuthenticationLogRepositoryInterface;
class UserAuthLogRepository extends EntityRepository implements
AuthenticationLogRepositoryInterface,
AuthenticationLogCreatorInterface
{
public function save(AuthenticationLogInterface $log): void
{
$this->getEntityManager()->persist($log);
$this->getEntityManager()->flush();
}
public function findExistingLog(UserIdentity $userIdentity, UserInformation $userInformation): bool
{
return null !== $this->findOneBy([
'userIdentifier' => $userIdentity->userIdentifier,
'userClass' => $userIdentity->userClass,
'ipAddress' => $userInformation->ipAddress,
]);
}
public function createLog(UserIdentity $userIdentity, UserInformation $userInformation): AuthenticationLogInterface
{
$user = $this->findUser($userIdentity);
if (null === $user) {
throw new \RuntimeException(sprintf('No user found for identifier "%s".', $userIdentity->userIdentifier));
}
return new UserAuthLog($user, $userIdentity, $userInformation);
}
private function findUser(UserIdentity $userIdentity): ?User
{
return $this->getEntityManager()->getRepository(User::class)->findOneBy([
'email' => $userIdentity->userIdentifier,
]);
}
}That's it! The bundle automatically listens to LoginSuccessEvent, checks if the login context is known, persists the log, and sends a notification email when a new context is detected.
GeoIP2 (local database) — recommended in production, no outbound call:
spiriit_auth_log:
location:
provider: 'geoip2'
geoip2_database_path: '%kernel.project_dir%/var/GeoLite2-City.mmdb'IP API (external API, 45 req/min free) — development only:
spiriit_auth_log:
location:
provider: 'ipApi'
⚠️ ipApisends the user's IP address to a third party in clear text. The free tier of ip-api.com exposes no HTTPS endpoint (only the paidpro.ip-api.comdoes), so the call is made over plainhttp://: the IP address of every authenticated user — personal data — is transmitted unencrypted to a third party you have no contract with, and the response is trusted as-is, so traffic tampering can display a fake location in the alert email. Keep it for development, usegeoip2in production, and declare the transfer in your record of processing activities if you use it anyway — see Geolocation.
spiriit_auth_log:
messenger: 'messenger.default_bus'Optional routing:
framework:
messenger:
routing:
'Spiriit\Bundle\AuthLogBundle\Messenger\AuthLoginMessage\AuthLoginMessage': asyncUpgrading to 3.0? The payload of
AuthLoginMessagechanged. Drain the queue before deploying, otherwise the messages still in flight are rejected — see UPGRADE.md.
When a new device/context is detected, the bundle dispatches a AuthenticationLogEvents::NEW_DEVICE event. You can listen to it for custom processing (logging, analytics, etc.):
use Spiriit\Bundle\AuthLogBundle\Listener\AuthenticationLogEvent;
use Spiriit\Bundle\AuthLogBundle\Listener\AuthenticationLogEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: AuthenticationLogEvents::NEW_DEVICE)]
final class NewDeviceListener
{
public function __invoke(AuthenticationLogEvent $event): void
{
$userIdentifier = $event->userIdentifier();
$userClass = $event->userIdentity()->userClass;
$userInformation = $event->userInformation();
$authenticationLog = $event->authenticationLog();
// your custom logic here
}
}Note: Persistence and notification are handled automatically by the bundle. You do not need to listen to this event for the bundle to work.
This optional feature adds two signed links to the notification email so the user can confirm the login was theirs — or report that it wasn't — from any device, without being logged in.
- The links are signed with your application secret and carry an expiration.
- Clicking a link opens an intermediate page with a confirmation button that POSTs the action. This prevents email link scanners (Outlook Safe Links, etc.) from triggering the action just by following the URL.
- A link is single-use: once the login is acknowledged or disavowed, replaying it shows an "already handled" page.
- If the login no longer exists (e.g. it was pruned by your retention policy), the page reports that the link is no longer valid, with a
404status — distinct from the "already handled" page.
The bundle only records the outcome and dispatches an event — your application decides what to do next (e.g. force a password change or log out other sessions on a disavow).
spiriit_auth_log:
confirmation:
enabled: true
token_ttl: '3 days' # relative expression: "12 hours", "1 week"...Generating absolute URLs from a Messenger worker (no request context) requires a default URI:
# config/packages/routing.yaml
framework:
router:
default_uri: 'https://your-domain.com'Add the trait and interface to the entity that already extends AbstractAuthenticationLog, then generate a migration for the new columns (confirmation_token, status, responded_at):
use Spiriit\Bundle\AuthLogBundle\Entity\AbstractAuthenticationLog;
use Spiriit\Bundle\AuthLogBundle\Entity\ConfirmableAuthenticationLogInterface;
use Spiriit\Bundle\AuthLogBundle\Entity\ConfirmableAuthenticationLogTrait;
#[ORM\Entity(repositoryClass: UserAuthLogRepository::class)]
class UserAuthLog extends AbstractAuthenticationLog implements ConfirmableAuthenticationLogInterface
{
use ConfirmableAuthenticationLogTrait;
// ... your existing fields
}If you don't enable the feature, nothing changes: the trait and columns are opt-in, so existing integrators don't need a migration.
use Spiriit\Bundle\AuthLogBundle\Entity\ConfirmableAuthenticationLogInterface;
use Spiriit\Bundle\AuthLogBundle\Repository\ConfirmableAuthenticationLogRepositoryInterface;
class UserAuthLogRepository extends EntityRepository implements
AuthenticationLogRepositoryInterface,
AuthenticationLogCreatorInterface,
ConfirmableAuthenticationLogRepositoryInterface
{
// ... existing methods
public function findOneByConfirmationToken(string $confirmationToken): ?ConfirmableAuthenticationLogInterface
{
return $this->findOneBy(['confirmationToken' => $confirmationToken]);
}
}You keep full control over the route. Pick one of the two approaches.
a. Import the default route (simplest). You may add a prefix, host or condition — the generated links follow it automatically:
# config/routes/spiriit_auth_log.yaml
spiriit_auth_log:
resource: '@SpiriitAuthLogBundle/config/routes.php'
prefix: /security # optionalb. Declare your own route and point the bundle at it — use this when you want your own path or format:
# config/routes.yaml
my_login_confirmation:
path: /account/logins/{action}/{token}
controller: spiriit_auth_log.confirmation_controller
methods: [GET, POST]
requirements: { action: 'acknowledge|disavow' }spiriit_auth_log:
confirmation:
enabled: true
route_name: my_login_confirmation # defaults to "spiriit_auth_log_confirm"The bundle dispatches AuthenticationLogEvents::LOGIN_ACKNOWLEDGED or AuthenticationLogEvents::LOGIN_DISAVOWED, carrying the confirmed log:
use Spiriit\Bundle\AuthLogBundle\Listener\AuthenticationLogConfirmationEvent;
use Spiriit\Bundle\AuthLogBundle\Listener\AuthenticationLogEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: AuthenticationLogEvents::LOGIN_DISAVOWED)]
final class LoginDisavowedListener
{
public function __invoke(AuthenticationLogConfirmationEvent $event): void
{
$log = $event->authenticationLog();
$user = $log->getUser();
// e.g. force a password reset, invalidate sessions, notify security...
}
}You can override the confirmation pages the same way as the email template, under
templates/bundles/SpiriitAuthLogBundle/confirmation/.
By default, the bundle sends email alerts via Symfony Mailer. To use a different transport (Slack, SMS, etc.), implement NotificationInterface and register it as a service:
use Spiriit\Bundle\AuthLogBundle\Notification\NewDeviceNotification;
use Spiriit\Bundle\AuthLogBundle\Notification\NotificationInterface;
final class SlackNotification implements NotificationInterface
{
public function send(NewDeviceNotification $notification): void
{
$notification->userReference; // email, display name, user identity
$notification->userInformation; // IP, user agent, location
$notification->authenticationLog; // the log that was just persisted
$notification->confirmationLinks; // null unless the confirmation feature is enabled
}
}Then point the mailer transport to your service ID:
spiriit_auth_log:
transports:
mailer: 'App\Notification\SlackNotification'
sender_email: 'no-reply@yourdomain.com'
sender_name: 'Security'You can override the default email template:
Create the file:
templates/bundles/SpiriitAuthLogBundle/new_device.html.twig
Available variables in the template:
| Variable | Type | Description |
|---|---|---|
userInformation.ipAddress |
?string |
Client IP address |
userInformation.userAgent |
?string |
Browser / device user agent |
userInformation.loginAt |
?DateTimeImmutable |
Login timestamp |
userInformation.location |
?LocateValues |
Geolocation (city, country, latitude, longitude) |
userReference.displayName |
string |
User display name |
userReference.email |
string |
User email |
userReference.userIdentity.userIdentifier |
string |
User identifier |
userReference.userIdentity.userClass |
string |
User class (FQCN) |
authenticationLog |
AuthenticationLogInterface |
The persisted log (getUser(), getLoginAt(), userIdentity()…) |
confirmationLinks |
?ConfirmationLinks |
acknowledgeUrl / disavowUrl — only set when the confirmation feature is enabled |
authenticableLog |
UserReference |
Deprecated alias of userReference, removed in 4.0 |
Internal flow when a user logs in:
LoginListenercatches Symfony'sLoginSuccessEvent- Builds a
LoginParameterDtofrom the request (IP, user agent) and the user (UserIdentity: identifier + class) - Dispatches to
LoginService(sync) orAuthLoginMessage(async via Messenger) LoginServicefetches geolocation data viaFetchUserInformationDoctrineAuthenticationLogHandlerchecks if the context is known for that identity (findExistingLog), and if not, creates and saves the log (createLog+save)- Dispatches
AuthenticationLogEvents::NEW_DEVICEwith the persisted log - Sends a
NewDeviceNotification(user reference, user information, log, confirmation links) viaNotificationInterface
composer test # Run the test suite
composer cs-check # Check code style (dry-run)
composer cs-fix # Fix code style
vendor/bin/phpstan analyse # Static analysisContributions are welcome! Please feel free to submit a Pull Request
This bundle is released under the MIT License. See the LICENSE file for details.
For questions and support, please contact dev@spiriit.com or open an issue on GitHub.

