Passwordless Login System in PHP (Magic Link via Email)

Password-based authentication is becoming outdated. Users forget passwords, reuse weak ones, and create security risks. A passwordless login system using magic links solves these issues by allowing users to log in via a secure link sent to their email. Passwordless Login System is a secure, modern passwordless authentication system built with PHP and MySQLi that uses magic links sent via email for user authentication.

In this tutorial, you’ll learn how to build a secure passwordless authentication system in PHP with MySQLi, using email-based magic links.

πŸš€ What You’ll Build

You will create a passwordless login system that allows users to log in using magic links sent to their email. The system will include:

  1. User Registration: Users can register with their email address.
  2. Magic Link Generation: When a user requests to log in, a unique magic link is generated and sent to their email.
  3. Magic Link Validation: When the user clicks the magic link, it is validated, and if valid, the user is logged in.
  4. Security Measures: The system will include security features such as token expiration and single-use tokens to prevent unauthorized access.

By the end of this tutorial, you will have a fully functional passwordless login system with:

  • Passwordless login (no passwords stored)
  • One-time secure magic links
  • Email-based authentication
  • Token expiration (1 hour)
  • Login history tracking
  • CSRF protection & SQL injection prevention

πŸ“‚ Project Structure

Here’s the project structure for the passwordless login system:

passwordless-login-system/
β”œβ”€β”€ config/
β”‚   └── database.php           # Database configuration & connection
β”œβ”€β”€ includes/
β”‚   β”œβ”€β”€ functions.php          # Core utility functions
β”‚   β”œβ”€β”€ header.php             # Header template
β”‚   └── footer.php             # Footer template
β”œβ”€β”€ public/
β”‚   └── style.css              # CSS styling
β”œβ”€β”€ index.php                  # Home page
β”œβ”€β”€ register.php               # Registration page
β”œβ”€β”€ login.php                  # Login page
β”œβ”€β”€ verify-link.php            # Magic link verification
β”œβ”€β”€ dashboard.php              # Protected dashboard
└── logout.php                 # Logout handler

πŸ› οΈ Database Setup

To set up the database, create a MySQL database and run the following SQL to create the necessary tables.

SQL to Create Users Table (users):

CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

SQL to Create Magic Tokens Table (magic_tokens):

CREATE TABLE magic_tokens (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    token VARCHAR(255) UNIQUE NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    is_used BOOLEAN DEFAULT FALSE,
    used_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_token (token),
    INDEX idx_expires_at (expires_at)
);

SQL to create Login History Table (optional – for audit logging):

CREATE TABLE login_history (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    ip_address VARCHAR(45),
    user_agent VARCHAR(255),
    login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_id (user_id),
    INDEX idx_login_time (login_time)
);

SQL to create Sessions Table (optional – for explicit session tracking):

CREATE TABLE sessions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    session_token VARCHAR(255) UNIQUE NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_id (user_id),
    INDEX idx_expires_at (expires_at)
);

πŸ”Œ Database Configuration & Connection (config/database.php)

Create a file named database.php inside the config/ directory. This file establishes a connection to the MySQL database using MySQLi and defines constants for database credentials and application configuration.

  • DB_HOST – Database host
  • DB_USER – Database user
  • DB_PASS – Database password
  • DB_NAME – Database name
  • APP_URL – Base URL of the application
  • SITE_NAME – Name of the site (for email templates)
  • FROM_EMAIL – Email address used as the sender for magic link emails
  • TOKEN_EXPIRY – Token expiry time in minutes (e.g., 30 minutes)
  • MAGIC_LINK_EXPIRY – Magic link expiry time in seconds (e.g., 3600 seconds for 1 hour)
<?php 
// Database credentials
define('DB_HOST''localhost');
define('DB_USER''root');
define('DB_PASS''');
define('DB_NAME''passwordless_login_db');

// Create connection using MySQLi
$conn = new mysqli(DB_HOSTDB_USERDB_PASSDB_NAME);

// Check connection
if ($conn->connect_error) {
    die(
"Connection failed: " $conn->connect_error);
}

// Set charset to utf8
$conn->set_charset("utf8mb4");

// Define app constants
define('APP_URL''Your_App_URL_Here');
define('SITE_NAME''Passwordless Login System');
define('FROM_EMAIL''sender@example.com');
define('TOKEN_EXPIRY'30); // Token expiry in minutes
define('MAGIC_LINK_EXPIRY'3600); // Magic link expiry in seconds (1 hour)
?>

πŸ”§ Core Functions (includes/functions.php)

Create a file named functions.php inside the includes/ directory. This file contains core utility functions for the passwordless login system, including:

  • generateToken($length = 32): Generates a secure random token of the specified length.
  • createMagicToken($conn, $user_id): Creates a magic token for the specified user.
  • verifyMagicToken($conn, $token): Verifies the provided magic token against the database.
  • markTokenAsUsed($conn, $token_id): Marks the specified magic token as used.
  • createOrGetUser($conn, $email, $name): Creates a new user or retrieves an existing user by email.
  • getUserById($conn, $user_id): Retrieves a user record from the database by ID.
  • getUserByEmail($conn, $email): Retrieves a user record from the database by email.
  • sendMagicLinkEmail($email, $name, $magic_link): Sends a magic link email to the user with the provided details.
  • logLoginActivity($conn, $user_id, $ip_address = null, $user_agent = null): Logs the user’s login activity.
  • createSessionToken($conn, $user_id): Creates a session token for the specified user.
  • verifySessionToken($conn, $token): Verifies the provided session token against the database.
  • invalidateSession($conn, $token): Invalidates the specified session token.
  • isAuthenticated(): Checks if the user is authenticated.
  • getCurrentUserId(): Returns the ID of the currently authenticated user.
<?php 
/**
 * Generate a secure random token
 */
function generateToken($length 64) {
    return 
bin2hex(random_bytes($length 2));
}

/**
 * Create a magic link token for user
 * @param mysqli $conn - Database connection
 * @param int $user_id - User ID
 * @return string - Generated token
 */
function createMagicToken($conn$user_id) {
    
$token generateToken();
    
$expires_at date('Y-m-d H:i:s'strtotime('+1 hour'));
    
    
$stmt $conn->prepare("INSERT INTO magic_tokens (user_id, token, expires_at) VALUES (?, ?, ?)");
    
$stmt->bind_param("iss"$user_id$token$expires_at);
    
    if (
$stmt->execute()) {
        
$stmt->close();
        return 
$token;
    }
    
    
$stmt->close();
    return 
false;
}

/**
 * Verify and validate magic token
 * @param mysqli $conn - Database connection
 * @param string $token - Token to verify
 * @return array|false - User data if valid, false otherwise
 */
function verifyMagicToken($conn$token) {
    
// Check if token exists and is not expired or already used
    
$stmt $conn->prepare("
        SELECT mt.id as token_id, mt.user_id, u.id, u.email, u.name, mt.is_used
        FROM magic_tokens mt
        JOIN users u ON mt.user_id = u.id
        WHERE mt.token = ? 
        AND mt.is_used = FALSE 
        AND mt.expires_at > NOW()
    "
);
    
    
$stmt->bind_param("s"$token);
    
$stmt->execute();
    
$result $stmt->get_result();
    
    if (
$result->num_rows === 0) {
        
$stmt->close();
        return 
false;
    }
    
    
$userData $result->fetch_assoc();
    
$stmt->close();
    
    return 
$userData;
}

/**
 * Mark magic token as used
 * @param mysqli $conn - Database connection
 * @param int $token_id - Magic token ID
 * @return bool - True if successful
 */
function markTokenAsUsed($conn$token_id) {
    
$used_at date('Y-m-d H:i:s');
    
    
$stmt $conn->prepare("UPDATE magic_tokens SET is_used = TRUE, used_at = ? WHERE id = ?");
    
$stmt->bind_param("si"$used_at$token_id);
    
    
$result $stmt->execute();
    
$stmt->close();
    
    return 
$result;
}

/**
 * Create or get user
 * @param mysqli $conn - Database connection
 * @param string $email - User email
 * @param string $name - User name
 * @return int|false - User ID if successful, false otherwise
 */
function createOrGetUser($conn$email$name) {
    
// Check if user already exists
    
$stmt $conn->prepare("SELECT id FROM users WHERE email = ?");
    
$stmt->bind_param("s"$email);
    
$stmt->execute();
    
$result $stmt->get_result();
    
    if (
$result->num_rows 0) {
        
$user $result->fetch_assoc();
        
$stmt->close();
        return 
$user['id'];
    }
    
    
$stmt->close();
    
    
// Create new user
    
$stmt $conn->prepare("INSERT INTO users (email, name) VALUES (?, ?)");
    
$stmt->bind_param("ss"$email$name);
    
    if (
$stmt->execute()) {
        
$user_id $conn->insert_id;
        
$stmt->close();
        return 
$user_id;
    }
    
    
$stmt->close();
    return 
false;
}

/**
 * Get user by ID
 * @param mysqli $conn - Database connection
 * @param int $user_id - User ID
 * @return array|false - User data if found, false otherwise
 */
function getUserById($conn$user_id) {
    
$stmt $conn->prepare("SELECT id, email, name, created_at FROM users WHERE id = ?");
    
$stmt->bind_param("i"$user_id);
    
$stmt->execute();
    
$result $stmt->get_result();
    
    if (
$result->num_rows === 0) {
        
$stmt->close();
        return 
false;
    }
    
    
$user $result->fetch_assoc();
    
$stmt->close();
    
    return 
$user;
}

/**
 * Get user by email
 * @param mysqli $conn - Database connection
 * @param string $email - User email
 * @return array|false - User data if found, false otherwise
 */
function getUserByEmail($conn$email) {
    
$stmt $conn->prepare("SELECT id, email, name, created_at FROM users WHERE email = ?");
    
$stmt->bind_param("s"$email);
    
$stmt->execute();
    
$result $stmt->get_result();
    
    if (
$result->num_rows === 0) {
        
$stmt->close();
        return 
false;
    }
    
    
$user $result->fetch_assoc();
    
$stmt->close();
    
    return 
$user;
}

/**
 * Send magic link email
 * @param string $email - Recipient email
 * @param string $name - Recipient name
 * @param string $magic_link - Complete magic link URL
 * @return bool - True if email sent successfully
 */
function sendMagicLinkEmail($email$name$magic_link) {
    
$subject "Your Magic Login Link - " SITE_NAME;
    
    
$message "
    <html>
        <head>
            <title>Magic Login Link</title>
        </head>
        <body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>
            <div style='max-width: 600px; margin: 0 auto; padding: 20px;'>
                <h2 style='color: #2c3e50;'>Welcome to " 
SITE_NAME "</h2>
                <p>Hi <strong>" 
htmlspecialchars($name) . "</strong>,</p>
                <p>You have requested a magic login link. Click the button below to login to your account:</p>
                
                <div style='text-align: center; margin: 30px 0;'>
                    <a href='" 
htmlspecialchars($magic_link) . "' 
                       style='display: inline-block; padding: 12px 30px; background-color: #3498db; 
                              color: white; text-decoration: none; border-radius: 5px; font-weight: bold;'>
                        Login to Your Account
                    </a>
                </div>
                
                <p>Or copy and paste this link in your browser:</p>
                <p style='word-break: break-all; background-color: #ecf0f1; padding: 10px; border-radius: 3px;'>
                    " 
htmlspecialchars($magic_link) . "
                </p>
                
                <p style='color: #e74c3c; font-weight: bold;'>
                    βš οΈ This link will expire in 1 hour.
                </p>
                
                <p>If you didn't request this link, you can safely ignore this email.</p>
                
                <hr style='border: none; border-top: 1px solid #ecf0f1; margin: 20px 0;'>
                <p style='color: #7f8c8d; font-size: 12px;'>
                    This is an automated email. Please don't reply to this message.
                </p>
            </div>
        </body>
    </html>
    "
;
    
    
// Email headers
    
$headers "MIME-Version: 1.0" "\r\n";
    
$headers .= "Content-type: text/html; charset=UTF-8" "\r\n";
    
//$headers .= "From: ".SITE_NAME." <noreply@" . $_SERVER['HTTP_HOST'] . ">\r\n";
    
$headers .= "From: ".SITE_NAME." <" FROM_EMAIL ">\r\n";
    
    
// Send email
    
return mail($email$subject$message$headers);
}

/**
 * Log login activity
 * @param mysqli $conn - Database connection
 * @param int $user_id - User ID
 * @param string $ip_address - User IP address
 * @param string $user_agent - User agent string
 * @return bool - True if logged successfully
 */
function logLoginActivity($conn$user_id$ip_address null$user_agent null) {
    if (
$ip_address === null) {
        
$ip_address $_SERVER['REMOTE_ADDR'] ?? '';
    }
    if (
$user_agent === null) {
        
$user_agent $_SERVER['HTTP_USER_AGENT'] ?? '';
    }
    
    
$stmt $conn->prepare("INSERT INTO login_history (user_id, ip_address, user_agent) VALUES (?, ?, ?)");
    
$stmt->bind_param("iss"$user_id$ip_address$user_agent);
    
    
$result $stmt->execute();
    
$stmt->close();
    
    return 
$result;
}

/**
 * Create session token
 * @param mysqli $conn - Database connection
 * @param int $user_id - User ID
 * @return string|false - Session token if successful, false otherwise
 */
function createSessionToken($conn$user_id) {
    
$session_token generateToken();
    
$expires_at date('Y-m-d H:i:s'strtotime('+30 days'));
    
    
$stmt $conn->prepare("INSERT INTO sessions (user_id, session_token, expires_at) VALUES (?, ?, ?)");
    
$stmt->bind_param("iss"$user_id$session_token$expires_at);
    
    if (
$stmt->execute()) {
        
$stmt->close();
        return 
$session_token;
    }
    
    
$stmt->close();
    return 
false;
}

/**
 * Verify session token
 * @param mysqli $conn - Database connection
 * @param string $token - Session token
 * @return int|false - User ID if valid, false otherwise
 */
function verifySessionToken($conn$token) {
    
$stmt $conn->prepare("
        SELECT user_id FROM sessions 
        WHERE session_token = ? 
        AND expires_at > NOW()
    "
);
    
    
$stmt->bind_param("s"$token);
    
$stmt->execute();
    
$result $stmt->get_result();
    
    if (
$result->num_rows === 0) {
        
$stmt->close();
        return 
false;
    }
    
    
$session $result->fetch_assoc();
    
$stmt->close();
    
    return 
$session['user_id'];
}

/**
 * Invalidate session
 * @param mysqli $conn - Database connection
 * @param string $token - Session token
 * @return bool - True if successful
 */
function invalidateSession($conn$token) {
    
$stmt $conn->prepare("DELETE FROM sessions WHERE session_token = ?");
    
$stmt->bind_param("s"$token);
    
    
$result $stmt->execute();
    
$stmt->close();
    
    return 
$result;
}

/**
 * Check if user is authenticated
 * @return bool - True if authenticated, false otherwise
 */
function isAuthenticated() {
    return isset(
$_SESSION['user_id']) && !empty($_SESSION['user_id']);
}

/**
 * Get current logged-in user ID
 * @return int|false - User ID if authenticated, false otherwise
 */
function getCurrentUserId() {
    if (
isAuthenticated()) {
        return 
$_SESSION['user_id'];
    }
    return 
false;
}

/**
 * Sanitize email
 * @param string $email - Email to sanitize
 * @return string - Sanitized email
 */
function sanitizeEmail($email) {
    return 
filter_var(trim($email), FILTER_SANITIZE_EMAIL);
}

/**
 * Validate email format
 * @param string $email - Email to validate
 * @return bool - True if valid, false otherwise
 */
function isValidEmail($email) {
    return 
filter_var($emailFILTER_VALIDATE_EMAIL) !== false;
}

/**
 * Sanitize name
 * @param string $name - Name to sanitize
 * @return string - Sanitized name
 */
function sanitizeName($name) {
    return 
htmlspecialchars(trim($name), ENT_QUOTES'UTF-8');
}
?>

Header & Footer Templates (includes/header.php & includes/footer.php)

Create two files named header.php and footer.php inside the includes/ directory. These files contain the HTML structure for the header and footer of the application, including links to CSS stylesheets and any necessary JavaScript files.

The header.php file includes the opening HTML tags, meta tags, and links to the CSS stylesheet. It also checks for user authentication and retrieves the current user’s information if authenticated.

<?php 
// Start session if not already started
if (session_status() === PHP_SESSION_NONE) {
    
session_start();
}

// Include configuration and functions
require_once __DIR__ '/../config/database.php';
require_once 
__DIR__ '/functions.php';

// Check authentication if needed
$is_authenticated isAuthenticated();
$current_user null;

if (
$is_authenticated) {
    
$current_user getUserById($conn$_SESSION['user_id']);
}
?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?php echo isset($page_title) ? htmlspecialchars($page_title) . ' - ' . SITE_NAME : SITE_NAME; ?></title> <link rel="stylesheet" href="<?php echo APP_URL; ?>/public/style.css"> </head> <body> <nav class="navbar"> <div class="container"> <div class="nav-brand"> <a href="<?php echo APP_URL; ?>/index.php"><?php echo SITE_NAME; ?></a> </div> <div class="nav-menu"> <?php if ($is_authenticated): ?> <span class="nav-item">Welcome, <?php echo htmlspecialchars($current_user['name']); ?></span> <a href="<?php echo APP_URL; ?>/dashboard.php" class="nav-item">Dashboard</a> <a href="<?php echo APP_URL; ?>/logout.php" class="nav-item btn-logout">Logout</a> <?php else: ?> <a href="<?php echo APP_URL; ?>/login.php" class="nav-item">Login</a> <a href="<?php echo APP_URL; ?>/register.php" class="nav-item">Register</a> <?php endif; ?> </div> </div> </nav> <div class="container">

The footer.php file includes the closing HTML tags.

    </div>

    <footer class="footer">
        <div class="container">
            <p>&copy; <?php echo date('Y'); ?> <?php echo SITE_NAME; ?>. All rights reserved.</p>
        </div>
    </footer>
</body>
</html>

Home Page (index.php)

The index.php file serves as the home page of the application. It includes the header and footer templates and displays a welcome message. If the user is authenticated, it shows a link to the dashboard; otherwise, it provides links to the registration and login pages.

<?php 
$page_title 
'Home';
require_once 
__DIR__ '/includes/header.php';
?> <div class="content-wrapper"> <div class="hero-section"> <h1>Welcome to <?php echo SITE_NAME; ?></h1> <p>A secure, passwordless authentication system using magic links sent to your email</p> <div class="cta-buttons"> <?php if (!isAuthenticated()): ?> <a href="<?php echo APP_URL; ?>/login.php" class="btn btn-primary">Login</a> <a href="<?php echo APP_URL; ?>/register.php" class="btn btn-secondary">Register</a> <?php else: ?> <a href="<?php echo APP_URL; ?>/dashboard.php" class="btn btn-primary">Go to Dashboard</a> <?php endif; ?> </div> </div> </div> <?php require_once __DIR__ '/includes/footer.php'?>

Registration Page (register.php)

The register.php file allows users to register by providing their name and email. It includes form validation and calls the createOrGetUser() function to create a new user or retrieve an existing user. Upon successful registration, it create a magic token and sends a magic link email to the user.

<?php 
$page_title 
'Register';
require_once 
__DIR__ '/includes/header.php';

// If already logged in, redirect to dashboard
if (isAuthenticated()) {
    
header('Location: ' APP_URL '/dashboard.php');
    exit();
}

$error '';
$success '';

if (
$_SERVER['REQUEST_METHOD'] === 'POST') {
    
$email sanitizeEmail($_POST['email'] ?? '');
    
$name sanitizeName($_POST['name'] ?? '');
    
    
// Validate inputs
    
if (empty($email)) {
        
$error 'Email is required';
    } elseif (!
isValidEmail($email)) {
        
$error 'Please enter a valid email address';
    } elseif (empty(
$name)) {
        
$error 'Name is required';
    } elseif (
strlen($name) > 100) {
        
$error 'Name must not exceed 100 characters';
    } else {
        
// Check if user already exists
        
if ($existing_user getUserByEmail($conn$email)) {
            
$error 'An account with this email already exists. Please <a href="' APP_URL '/login.php">login instead</a>.';
        } else {
            
// Create user
            
$user_id createOrGetUser($conn$email$name);
            
            if (
$user_id) {
                
// Create magic token
                
$token createMagicToken($conn$user_id);
                
                if (
$token) {
                    
// Create magic link
                    
$magic_link APP_URL '/verify-link.php?token=' $token;
                    
                    
// Send email
                    
if (sendMagicLinkEmail($email$name$magic_link)) {
                        
$success 'Registration successful! Please check your email for a magic link to complete registration.';
                    } else {
                        
$error 'Registration successful, but we could not send the magic link email. Please try to <a href="' APP_URL '/login.php">login</a> and request a new link.';
                    }
                } else {
                    
$error 'Error creating login token. Please try again.';
                }
            } else {
                
$error 'An error occurred during registration. Please try again.';
            }
        }
    }
}
?> <div class="auth-container"> <div class="auth-box"> <h1>Create Account</h1> <p class="auth-subtitle">Join us to get started</p> <?php if (!empty($error)): ?> <div class="alert alert-error"> <?php echo $error; ?> </div> <?php endif; ?> <?php if (!empty($success)): ?> <div class="alert alert-success"> <?php echo $success; ?> </div> <?php endif; ?> <form method="POST" class="auth-form"> <div class="form-group"> <label for="name">Full Name</label> <input type="text" id="name" name="name" required placeholder="Enter your full name" value="<?php echo htmlspecialchars($_POST['name'] ?? ''); ?>"> </div> <div class="form-group"> <label for="email">Email Address</label> <input type="email" id="email" name="email" required placeholder="Enter your email address" value="<?php echo htmlspecialchars($_POST['email'] ?? ''); ?>"> </div> <button type="submit" class="btn btn-primary btn-block">Register</button> </form> <p class="auth-footer"> Already have an account? <a href="<?php echo APP_URL; ?>/login.php">Login here</a> </p> </div> </div> <?php require_once __DIR__ '/includes/footer.php'?>

Login Page (login.php)

The login.php file allows users to request a magic link for login. Users enter their email, and if the email exists in the database, a magic token is created and a magic link email is sent to the user.

<?php 
$page_title 
'Login';
require_once 
__DIR__ '/includes/header.php';

// If already logged in, redirect to dashboard
if (isAuthenticated()) {
    
header('Location: ' APP_URL '/dashboard.php');
    exit();
}

$error '';
$success '';

if (
$_SERVER['REQUEST_METHOD'] === 'POST') {
    
$email sanitizeEmail($_POST['email'] ?? '');
    
    
// Validate input
    
if (empty($email)) {
        
$error 'Email is required';
    } elseif (!
isValidEmail($email)) {
        
$error 'Please enter a valid email address';
    } else {
        
// Check if user exists
        
$user getUserByEmail($conn$email);
        
        if (!
$user) {
            
// Security: Don't reveal if email exists
            
$error 'If an account exists with this email, a magic link has been sent to your inbox.';
        } else {
            
// Create magic token
            
$token createMagicToken($conn$user['id']);
            
            if (
$token) {
                
// Create magic link
                
$magic_link APP_URL '/verify-link.php?token=' $token;
                
                
// Send email
                
if (sendMagicLinkEmail($email$user['name'], $magic_link)) {
                    
$success 'If an account exists with this email, a magic link has been sent to your inbox. Please check your email (and spam folder).';
                } else {
                    
$error 'Error sending magic link email. Please try again later.';
                }
            } else {
                
$error 'Error creating login token. Please try again.';
            }
        }
    }
}
?> <div class="auth-container"> <div class="auth-box"> <h1>Login</h1> <p class="auth-subtitle">Enter your email to receive a magic login link</p> <?php if (!empty($error)): ?> <div class="alert alert-error"> <?php echo $error; ?> </div> <?php endif; ?> <?php if (!empty($success)): ?> <div class="alert alert-success"> <?php echo $success; ?> </div> <?php endif; ?> <form method="POST" class="auth-form"> <div class="form-group"> <label for="email">Email Address</label> <input type="email" id="email" name="email" required placeholder="Enter your email address" value="<?php echo htmlspecialchars($_POST['email'] ?? ''); ?>"> </div> <button type="submit" class="btn btn-primary btn-block">Send Magic Link</button> </form> <p class="auth-footer"> Don't have an account? <a href="<?php echo APP_URL; ?>/register.php">Register here</a> </p> </div> </div> <?php require_once __DIR__ '/includes/footer.php'?>

Magic Link Verification (verify-link.php)

The verify-link.php file handles the verification of magic links. It retrieves the token from the URL, verifies it using the verifyMagicToken() function, and if valid, marks the token as used, creates a session token, logs the login activity, and redirects the user to the dashboard.

<?php 
$page_title 
'Verify Login';
require_once 
__DIR__ '/includes/header.php';

// If already logged in, redirect to dashboard
if (isAuthenticated()) {
    
header('Location: ' APP_URL '/dashboard.php');
    exit();
}

$error '';
$success '';
$token trim($_GET['token'] ?? '');

if (empty(
$token)) {
    
$error 'Invalid verification link. No token provided.';
} else {
    
// Verify the token
    
$user_data verifyMagicToken($conn$token);
    
    if (!
$user_data) {
        
$error 'The magic link is invalid, expired, or has already been used. Please <a href="' APP_URL '/login.php">request a new one</a>.';
    } else {
        
// Token is valid
        
$user_id $user_data['user_id'];
        
$token_id $user_data['token_id'];
        
        
// Mark token as used
        
if (markTokenAsUsed($conn$token_id)) {
            
// Log login activity
            
logLoginActivity($conn$user_id);
            
            
// Create session
            
$_SESSION['user_id'] = $user_id;
            
$_SESSION['user_email'] = $user_data['email'];
            
$_SESSION['user_name'] = $user_data['name'];
            
            
$success 'Login successful! Redirecting to dashboard...';
            
            
// Redirect to dashboard
            
header('Refresh: 2; URL=' APP_URL '/dashboard.php');
        } else {
            
$error 'Error during login process. Please try again.';
        }
    }
}
?> <div class="verify-container"> <div class="verify-box"> <h1>Email Verification</h1> <?php if (!empty($error)): ?> <div class="alert alert-error"> <?php echo $error; ?> </div> <div class="action-buttons"> <a href="<?php echo APP_URL; ?>/login.php" class="btn btn-primary">Back to Login</a> </div> <?php elseif (!empty($success)): ?> <div class="alert alert-success"> <?php echo $success; ?> </div> <p style="text-align: center; margin-top: 20px;"> <a href="<?php echo APP_URL; ?>/dashboard.php" class="btn btn-primary">Go to Dashboard</a> </p> <?php else: ?> <div class="alert alert-info"> Verifying your email... </div> <?php endif; ?> </div> </div> <?php require_once __DIR__ '/includes/footer.php'?>

Dashboard (dashboard.php)

The dashboard.php file is a protected page that only authenticated users can access. It includes the header and footer templates and displays a welcome message to the logged-in user, along with a logout link.

<?php 
$page_title 
'Dashboard';
require_once 
__DIR__ '/includes/header.php';

// Check if user is authenticated
if (!isAuthenticated()) {
    
header('Location: ' APP_URL '/login.php');
    exit();
}

$user getUserById($conn$_SESSION['user_id']);

// Get login history
$stmt $conn->prepare("
    SELECT login_time, ip_address, user_agent 
    FROM login_history 
    WHERE user_id = ? 
    ORDER BY login_time DESC 
    LIMIT 10
"
);
$stmt->bind_param("i"$_SESSION['user_id']);
$stmt->execute();
$login_history $stmt->get_result();
$stmt->close();
?> <div class="dashboard-container"> <div class="dashboard-header"> <h1>Welcome, <?php echo htmlspecialchars($user['name']); ?>!</h1> <p class="dashboard-subtitle">You are securely logged in</p> </div> <div class="dashboard-content"> <div class="info-card"> <h2>Account Information</h2> <div class="info-row"> <span class="info-label">Email:</span> <span class="info-value"><?php echo htmlspecialchars($user['email']); ?></span> </div> <div class="info-row"> <span class="info-label">Name:</span> <span class="info-value"><?php echo htmlspecialchars($user['name']); ?></span> </div> <div class="info-row"> <span class="info-label">Member Since:</span> <span class="info-value"><?php echo date('F j, Y', strtotime($user['created_at'])); ?></span> </div> </div> <div class="info-card"> <h2>Recent Login History</h2> <?php if ($login_history->num_rows > 0): ?> <div class="table-responsive"> <table class="history-table"> <thead> <tr> <th>Date & Time</th> <th>IP Address</th> <th>User Agent</th> </tr> </thead> <tbody> <?php while ($row = $login_history->fetch_assoc()): ?> <tr> <td><?php echo date('M j, Y H:i:s', strtotime($row['login_time'])); ?></td> <td><?php echo htmlspecialchars($row['ip_address'] ?? 'Unknown'); ?></td> <td class="truncate"><?php echo htmlspecialchars($row['user_agent'] ?? 'Unknown', ENT_QUOTES); ?></td> </tr> <?php endwhile; ?> </tbody> </table> </div> <?php else: ?> <p>No login history available yet.</p> <?php endif; ?> </div> <div class="action-card"> <h2>Account Actions</h2> <a href="<?php echo APP_URL; ?>/logout.php" class="btn btn-danger">Logout</a> </div> </div> </div> <?php require_once __DIR__ '/includes/footer.php'?>

Logout Handler (logout.php)

The logout.php file handles user logout by invalidating the session token and destroying the session. It then redirects the user back to the home page.

<?php 
/**
 * Logout Script
 */
require_once __DIR__ '/config/database.php';
require_once 
__DIR__ '/includes/functions.php';

// Start session if not already started
if (session_status() === PHP_SESSION_NONE) {
    
session_start();
}

// Destroy session
if (isset($_SESSION['user_id'])) {
    
$user_id $_SESSION['user_id'];
    
    
// Optional: Log logout activity
    // logLogoutActivity($conn, $user_id);
}

// Clear session data
$_SESSION = array();

// Destroy the session cookie
if (ini_get("session.use_cookies")) {
    
$params session_get_cookie_params();
    
setcookie(session_name(), ''time() - 42000,
        
$params["path"], $params["domain"],
        
$params["secure"], $params["httponly"]
    );
}

// Destroy session
session_destroy();

// Redirect to home page
header('Location: ' APP_URL '/index.php?logout=success');
exit();
?>

πŸ›‘οΈ Security Best Practices

In this passwordless login system using PHP and MySQLi, we have implemented several security best practices to ensure the safety of user data and prevent unauthorized access. Here are some key security measures to consider when building a passwordless authentication system:

  • Token Expiration: Implement token expiration to limit the validity of magic links and session tokens, reducing the risk of unauthorized access if a token is compromised.
  • Single-Use Tokens: Ensure that magic tokens can only be used once to prevent reuse by attackers.
  • Input Validation: Validate and sanitize all user inputs to prevent SQL injection and other forms of attacks.
  • CSRF Protection: Implement CSRF protection for all forms to prevent cross-site request forgery attacks.
  • Secure Email Sending: Use a secure method for sending emails, such as SMTP with proper authentication, to prevent email spoofing and ensure that magic links are delivered securely.
  • Audit Logging: Log all login attempts and activities to monitor for suspicious behavior and potential security breaches.
  • Session Management: Implement secure session management practices, such as regenerating session tokens on login and logout, and setting appropriate session timeouts.

πŸ“Œ Conclusion

You’ve now built a secure passwordless login system in PHP using magic links. By leveraging magic links sent via email, you have created a modern authentication system that eliminates the need for passwords, enhancing security and user experience. This modern authentication approach improves both security and user experience, making it ideal for SaaS apps, admin panels, and membership systems. You can further enhance this system by adding features like multi-factor authentication, social login integration, and more robust user management capabilities. Happy coding!

Looking for expert assistance to implement or extend this script’s functionality? Submit a Service Request

Leave a reply

construction Need this implemented in your project? Request Implementation Help β†’ keyboard_double_arrow_up