---
title: "Security and Privacy"
description: "Security model, data handling, and privacy for Login for Stripe Customer Portal."
url: "https://docs.customerportalplugin.com/security-and-privacy/security-and-privacy/"
---
## Authentication model

The plugin uses **passwordless magic-link authentication**:

*   Customers prove access to an **email inbox** — not a WordPress password.
*   Links contain a random **one-time token** generated with `wp_generate_password` (20 characters).
*   The token itself is never persisted. The plugin stores a **SHA-256 hash** of the token in a WordPress transient (key prefix `lscp_stripe_login_token_`) with a **one-hour TTL**, mapped to the customer’s email. A database snapshot therefore does not expose unredeemed login links.
*   On click, the plugin re-hashes the URL token, looks up and immediately **deletes** the transient, then provisions the Stripe Billing Portal session.

Form submissions require a valid WordPress **nonce** to mitigate CSRF, and the admin settings page enforces the `manage_options` capability before rendering or saving.

## Rate limiter

v1.0.6 introduces a per-identity rate limiter so the magic-link form can’t be abused as a mail relay or enumeration oracle:

| Identity | Limit | Window |
| --- | --- | --- |
| Lowercase email address | 5 requests | 10 minutes |
| Submitting IP address | 5 requests | 10 minutes |

Counters are stored as transients keyed by `SHA-256(identity)`, never the raw email or IP. When either bucket overflows, the form returns *“Too many requests. Please wait a few minutes and try again.”*

An administrator can clear a stuck counter with WP-CLI:

```
wp lscp limiter-reset customer@example.com
```

## Email enumeration

The form response is constant for valid, invalid, and unknown emails — and the wording is **mode-aware**:

| Setting | Confirmation message |
| --- | --- |
| “Existing Stripe customers only” **checked** | *“If your email address is associated with a Stripe customer, a login link is on its way. Please check your inbox.”* |
| “Existing Stripe customers only” **unchecked** (default) | *“A login link is on its way. Please check your inbox for the link to access your Stripe Customer Portal.”* |

In the default mode the response is unconditional — nothing in it reveals whether a Stripe customer already exists for that email (because the plugin will create one on first redemption). In the gated mode the conditional wording is honest about the gating without confirming customer existence.

## Data stored in WordPress

| Data | Storage | Notes |
| --- | --- | --- |
| Stripe Secret API key | `wp_options` (`lscp_stripe_api_key`) | Fixed-length 12-char mask in admin UI |
| Redirect URL | `wp_options` (`lscp_stripe_redirect_url`) |  |
| Portal slug | `wp_options` (`lscp_stripe_endpoint_slug`) | Sanitized, max 64 chars |
| Existing-customer flag | `wp_options` (`lscp_stripe_validate_existing_customers`) | `0` or `1` |
| Login tokens (hashed) | Transients (`lscp_stripe_login_token_{sha256(token)}`) | Email value, 1h TTL, one-time use |
| Rate-limit counters | Transients keyed by `sha256(identity)` | 10-minute window per email and per IP |

The plugin does **not** store customer payment details locally. Billing data remains in Stripe.

## Data sent to Stripe

When a customer uses the login flow, your server calls Stripe's API to:

*   Search for customers by email
*   Create a customer (when allowed)
*   Create a **Billing Portal session**

By using this plugin, you consent to transmitting customer email addresses and related API data to Stripe under their terms.

*   [Stripe Terms of Service](https://stripe.com/legal/ssa)
*   [Stripe Privacy Policy](https://stripe.com/privacy)

## GDPR / Privacy Tools

v1.0.6 registers **personal-data exporter and eraser callbacks** with WordPress’s Privacy Tools so site owners can satisfy data-subject access and erasure requests through **Tools → Export Personal Data** and **Tools → Erase Personal Data**.

The exporter surfaces what the plugin holds for a given email:

*   Whether an outstanding magic-link transient exists for that email (without revealing the token itself).
*   Whether an active rate-limit counter exists for that email.

The eraser deletes both. The plugin intentionally holds almost no PII at rest, so the privacy surface is minimal — but the WP Privacy Tools wiring is present for compliance workflows.

## Third-party services

### Stripe

Required for Customer Portal functionality. All portal UI and payment method storage is on Stripe infrastructure.

### Freemius

The free plugin includes the **Freemius SDK** for licensing infrastructure and optional usage insights. On first activation you may see an opt-in prompt. Freemius may collect basic site and plugin metadata if you opt in. See Freemius privacy documentation linked from the opt-in screen.

## Requirements

| Requirement | Version |
| --- | --- |
| WordPress | 5.0+ (tested up to 6.9) |
| PHP | **7.4+** (bumped from 7.0 in v1.0.6; PHP 7.0–7.3 are end-of-life) |
| Stripe PHP SDK (bundled) | 16.1.0 |
| Stripe API version (bundled SDK) | 2024-09-30.acacia |

## Server requirements

*   **Pretty permalinks** recommended for the custom login endpoint
*   Working **`wp_mail()`** or SMTP for magic-link delivery
*   Outbound HTTPS to Stripe API endpoints
*   WordPress **Transients API** (default in WordPress)

## WP-CLI commands

v1.0.6 ships an operator surface under `wp lscp`:

| Command | Purpose |
| --- | --- |
| `wp lscp purge-tokens` | Delete every outstanding magic-link token. |
| `wp lscp limiter-reset <email>` | Clear rate-limit counters for that email. |
| `wp lscp send <email>` | Send a magic link without going through the form (useful for delivery tests). |
| `wp lscp config` | Dump current plugin settings; the Stripe Secret key is masked. |

A daily **WP-Cron sweep** also removes expired magic-link tokens and rate-limit counters — WordPress’s built-in transient GC is lazy and can otherwise leave expired rows in `wp_options` indefinitely on low-traffic sites.

## Extensibility

v1.0.6 does **not** expose filters for:

*   Email subject/body
*   Return URL override
*   Portal session parameters

The plugin is refactored into focused units (`Settings`, `TokenStore`, `TokenGC`, `StripeGateway`, `Mailer`, `RateLimiter`, `PortalController`, `FormRenderer`, `RewriteEndpoint`, `Shortcode`, `Privacy`, `Cli`, `Uninstall`), and the public class surface on `LSCP\Plugin` from 1.0.x is preserved — but no public filter or action hooks are documented yet beyond `lscp_fs_loaded` (fired when the Freemius SDK initializes).

## Uninstall

v1.0.6 ships a proper `uninstall.php` routine. When you delete the plugin from the **Plugins** screen, every option and transient LSCP wrote is removed automatically:

*   All four `lscp_stripe_*` options (Secret key, Redirect URL, slug, existing-customer flag).
*   All `lscp_stripe_login_token_*` transients.
*   All `lscp_rl_*` rate-limiter transients.

The cleanup is wired through Freemius’s `after_uninstall` hook, so the uninstall feedback survey still fires. The Stripe Secret Key no longer lingers in `wp_options` after deletion.

To force the same cleanup without deleting the plugin (e.g. on a staging environment), you can still drop the rows manually:

```
wp lscp purge-tokens
wp option delete lscp_stripe_api_key
wp option delete lscp_stripe_redirect_url
wp option delete lscp_stripe_endpoint_slug
wp option delete lscp_stripe_validate_existing_customers
```

## Related docs

[Login Flow›](../../usage/login-flow/) [FAQs›](../../faqs/faqs/)
