Skip to main content

Overview

The Password Reset API provides a secure, token-based password recovery system. When a user requests a password reset, the API generates a unique token, stores it in the database with an expiration time, and sends a branded email with a reset link.
This API implements rate limiting (3 requests per 15 minutes per email) to prevent abuse.

Request Password Reset

POST /api/forgot_password.php
Content-Type: application/x-www-form-urlencoded

email=[email protected]
Initiates the password reset process by generating a secure token and sending a recovery email. For security reasons, the API always returns a success message regardless of whether the email exists.

Request Parameters

email
string
required
The email address associated with the user account. Must be a valid email format.

Response Fields

ok
boolean
Indicates if the request was processed (not whether the email exists)
msg
string
Generic success message that doesn’t reveal if the email exists in the database
_dev_token
string
Development only: The generated token for testing. Only included when APP_ENV=development. Never exposed in production.

Security Features

Email Obfuscation

The API never reveals whether an email exists in the database:
if (!$usuario) {
    // Same response whether email exists or not
    echo json_encode([
        'ok' => true, 
        'msg' => 'Si el correo está registrado, recibirás un enlace...'
    ]);
    exit;
}
This prevents attackers from using the API to enumerate valid email addresses.

Rate Limiting

Maximum 3 requests per email address within a 15-minute window:
$stmtLimit = $pdo->prepare("
    SELECT COUNT(*) as cnt FROM password_resets 
    WHERE email = ? 
      AND created_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 15 MINUTE)
");
$stmtLimit->execute([$email]);

if ($stmtLimit->fetch()['cnt'] >= 3) {
    echo json_encode([
        'ok' => false, 
        'msg': 'Demasiadas solicitudes. Espera 15 minutos...'
    ]);
    exit;
}

Secure Token Generation

Tokens are cryptographically secure, 64-character hexadecimal strings:
// Generates 32 random bytes, converted to 64-char hex string
$token = bin2hex(random_bytes(32));
// Example: "a1b2c3d4e5f6789012345678901234567890abcdef..."

Token Expiration

Tokens expire 1 hour after creation:
INSERT INTO password_resets (email, token, expira) 
VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 1 HOUR))

Token Invalidation

Previous unused tokens are automatically invalidated when a new request is made:
// Mark all previous tokens as used
UPDATE password_resets 
SET usado = 1 
WHERE email = ? AND usado = 0

Email Template

The API sends a branded HTML email using the Brevo API (with fallback to PHP’s mail() function):
Subject: Restablecer contraseña — Computécnicos

<!DOCTYPE html>
<html>
<body style="background:#111;font-family:Arial,Helvetica,sans-serif">
<div style="max-width:520px;margin:40px auto;background:#1a1a1a;border:1px solid #333">
    <!-- Header with gradient -->
    <div style="background:linear-gradient(135deg,#cc0000,#ff0000);padding:28px 32px">
        <h1 style="color:#fff;font-size:22px;font-weight:800">
            COMPU<span style="font-weight:400">TÉCNICOS</span>
        </h1>
    </div>
    
    <!-- Body -->
    <div style="padding:32px">
        <h2 style="color:#fff">Hola, Juan Pérez</h2>
        <p style="color:#999">
            Recibimos una solicitud para restablecer la contraseña de tu cuenta...
        </p>
        
        <!-- Reset button -->
        <div style="text-align:center;margin:24px 0">
            <a href="https://ejemplo.com/reset_password.php?token=abc123..."
               style="background:linear-gradient(135deg,#cc0000,#ff0000);
                      color:#fff;font-weight:800;padding:14px 36px;
                      text-decoration:none">
                RESTABLECER CONTRASEÑA
            </a>
        </div>
        
        <p style="color:#666;font-size:12px">
            Este enlace expira en <strong>1 hora</strong>.
        </p>
    </div>
</div>
</body>
</html>

Reset URL Construction

The reset URL is built using the APP_URL environment variable:
$appUrl = $_ENV['APP_URL'] ?? base_url();
$resetUrl = rtrim($appUrl, '/') . '/reset_password.php?token=' . $token;

// Example: https://computecnicos.com/reset_password.php?token=a1b2c3...
The API always uses the production URL for reset links, even in local development. This ensures emails sent from staging/dev environments work correctly.

Validation Rules

// Email validation
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo json_encode([
        'ok' => false, 
        'msg' => 'Ingresa un correo electrónico válido.'
    ]);
    exit;
}

Error Responses

ok
boolean
false indicates an error condition
msg
string
Error message explaining the failure
Possible Errors:
  • Método no permitido - Only POST requests are allowed (405)
  • Ingresa un correo electrónico válido. - Invalid email format
  • Demasiadas solicitudes. Espera 15 minutos... - Rate limit exceeded

Implementation Example

<form id="forgot-password-form">
    <label for="email">Correo electrónico:</label>
    <input type="email" name="email" id="email" required 
           placeholder="[email protected]">
    <button type="submit">Enviar enlace de recuperación</button>
    <div id="message"></div>
</form>

<script>
document.getElementById('forgot-password-form')
    .addEventListener('submit', async (e) => {
        e.preventDefault();
        
        const formData = new FormData(e.target);
        const messageDiv = document.getElementById('message');
        const submitBtn = e.target.querySelector('button');
        
        // Disable button during request
        submitBtn.disabled = true;
        submitBtn.textContent = 'Enviando...';
        
        try {
            const response = await fetch('/api/forgot_password.php', {
                method: 'POST',
                body: formData
            });
            
            const result = await response.json();
            
            if (result.ok) {
                messageDiv.innerHTML = `
                    <div class="success">
                        ${result.msg}
                        <br><br>
                        Revisa tu bandeja de entrada y spam.
                    </div>
                `;
                e.target.reset();
            } else {
                messageDiv.innerHTML = `
                    <div class="error">${result.msg}</div>
                `;
                submitBtn.disabled = false;
                submitBtn.textContent = 'Enviar enlace de recuperación';
            }
        } catch (error) {
            messageDiv.innerHTML = `
                <div class="error">
                    Error de conexión. Intenta de nuevo.
                </div>
            `;
            submitBtn.disabled = false;
            submitBtn.textContent = 'Enviar enlace de recuperación';
        }
    });
</script>

<style>
.success {
    background: #d4edda;
    color: #155724;
    padding: 12px;
    border-radius: 4px;
    border: 1px solid #c3e6cb;
}

.error {
    background: #f8d7da;
    color: #721c24;
    padding: 12px;
    border-radius: 4px;
    border: 1px solid #f5c6cb;
}
</style>

Token Verification & Reset

While the token verification and password reset are handled by a separate page (reset_password.php), here’s how to verify and use a token:

Verify Token

<?php
// In reset_password.php

$token = $_GET['token'] ?? '';

if (empty($token)) {
    die('Token no proporcionado');
}

// Verify token is valid and not expired
$stmt = $pdo->prepare("
    SELECT email FROM password_resets 
    WHERE token = ? 
      AND usado = 0 
      AND expira > UTC_TIMESTAMP()
    LIMIT 1
");
$stmt->execute([$token]);
$reset = $stmt->fetch();

if (!$reset) {
    die('Token inválido o expirado');
}

// Token is valid, show password reset form
$email = $reset['email'];
?>

Update Password

<?php
// After user submits new password

$token = $_POST['token'];
$newPassword = $_POST['password'];

// Validate password (minimum 8 characters, etc.)
if (strlen($newPassword) < 8) {
    die('La contraseña debe tener al menos 8 caracteres');
}

// Verify token again
$stmt = $pdo->prepare("
    SELECT email FROM password_resets 
    WHERE token = ? AND usado = 0 AND expira > UTC_TIMESTAMP()
");
$stmt->execute([$token]);
$reset = $stmt->fetch();

if (!$reset) {
    die('Token inválido o expirado');
}

// Update password
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE usuarios SET password = ? WHERE email = ?");
$stmt->execute([$hashedPassword, $reset['email']]);

// Mark token as used
$stmt = $pdo->prepare("UPDATE password_resets SET usado = 1 WHERE token = ?");
$stmt->execute([$token]);

// Success
echo 'Contraseña actualizada correctamente';
?>

Database Schema

password_resets Table

CREATE TABLE password_resets (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    token VARCHAR(64) NOT NULL UNIQUE,
    expira DATETIME NOT NULL,
    usado TINYINT(1) DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_token (token),
    INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
The table is automatically created if it doesn’t exist when the API is first called.

Schema Fields

  • email: User’s email address (not a foreign key for security)
  • token: Unique 64-character hexadecimal string
  • expira: UTC timestamp when token expires (1 hour from creation)
  • usado: Flag indicating if token has been used (0 = unused, 1 = used)
  • created_at: UTC timestamp when token was created

Indexes

  • idx_token: Fast lookup by token string
  • idx_email: Fast lookup for rate limiting queries

Email Configuration

The API uses the enviar_email() helper function from app/Core/email_helper.php, which supports:
  1. Brevo API (primary): Transactional email service
  2. PHP mail() (fallback): System mail function

Environment Variables

# .env file
APP_URL=https://computecnicos.com
APP_ENV=production  # or 'development'

# Brevo API (optional)
BREVO_API_KEY=xkeysib-your-api-key-here
BREVO_SENDER_EMAIL=[email protected]
BREVO_SENDER_NAME=Computécnicos

Email Helper Usage

require_once __DIR__ . '/../app/Core/email_helper.php';

$enviado = enviar_email(
    $destinatario,  // recipient email
    $asunto,        // subject line
    $htmlBody       // HTML email body
);

if ($enviado) {
    log_event("Password reset email sent to: $destinatario");
} else {
    log_event("Failed to send password reset to: $destinatario");
}

Testing

Development Mode

When APP_ENV=development, the API includes the generated token in the response:
{
  "ok": true,
  "msg": "Si el correo está registrado...",
  "_dev_token": "a1b2c3d4e5f6789012345678901234567890abcdef0123456789012345678901"
}
Use this token to construct the reset URL manually during testing:
http://localhost/reset_password.php?token=a1b2c3d4e5f6789012345678...
The _dev_token field is never included in production (APP_ENV=production).

Testing Rate Limiting

# Send 4 requests rapidly to trigger rate limiting
for i in {1..4}; do
    curl -X POST http://localhost/api/forgot_password.php \
         -d "[email protected]"
    sleep 1
done

# 4th request should return:
# {"ok":false,"msg":"Demasiadas solicitudes. Espera 15 minutos..."}

Testing Token Expiration

-- Manually expire a token for testing
UPDATE password_resets 
SET expira = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 HOUR)
WHERE token = 'your-token-here';

-- Verify token is now expired
SELECT *, 
       (expira > UTC_TIMESTAMP()) as es_valido 
FROM password_resets 
WHERE token = 'your-token-here';

Security Best Practices

Implemented Security Measures
  • ✅ Cryptographically secure token generation (random_bytes(32))
  • ✅ Token expiration (1 hour)
  • ✅ Rate limiting (3 requests / 15 minutes)
  • ✅ Email enumeration prevention
  • ✅ Token invalidation on password reset
  • ✅ HTTPS-only URLs in production
  • ✅ UTC timestamps to prevent timezone issues
  • ✅ Unique token constraint in database
Additional Recommendations
  1. Always use HTTPS in production to prevent token interception
  2. Configure SPF/DKIM for your email domain to prevent spoofing
  3. Monitor logs for suspicious patterns (many failed requests)
  4. Implement CAPTCHA if abuse becomes an issue
  5. Log password reset events for security auditing

Logging

The API logs all password reset attempts:
// On success
log_event("Password reset solicitado para: $email");

// On email failure
log_event("Error enviando email de password reset a: $email");
Check logs regularly for suspicious activity:
grep "Password reset" /var/log/app.log | tail -n 50

Build docs developers (and LLMs) love