> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fingerprint.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Paywall Enforcement

> Learn how to implement paywall protection and prevent content bypass

## Overview

This tutorial walks through implementing Fingerprint to enforce paywalls, ensuring that visitors cannot bypass limits or access premium content by clearing cookies, changing IP addresses, or using incognito mode.

You'll begin with a starter app that includes a mock news site with sample articles and a basic paywall flow that limits the number of articles you can read for free. From there, you'll add the JavaScript agent to identify each visitor and use server-side logic with Fingerprint data to track article views and block further access once the limit is reached.

By the end, you'll have a sample app that reliably enforces paywall limits per visitor and can be customized to fit your workspace or content access model.

This tutorial uses just plain JavaScript and a Node server with SQLite on the backend. For language- or framework-specific setups, see the quickstarts.

> Estimated time: \< 15 minutes

<iframe className="w-full aspect-video rounded-md" src="https://www.youtube.com/embed/M5IohUWJd2o" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen />

## Prerequisites

Before you begin, make sure you have the following:

* A copy of the [starter repository](https://github.com/fingerprintjs/use-case-tutorials) (clone with Git or download as a ZIP)
* [Node.js](https://nodejs.org/) (v20 or later) and npm installed
* Your favorite code editor
* Basic knowledge of JavaScript

## 1. Create a Fingerprint account and get your API keys

1. [Sign up](https://dashboard.fingerprint.com/signup) for a free Fingerprint trial, or log in if you already have an account.
2. After signing in, go to the [**API keys**](https://dashboard.fingerprint.com/api-keys) page in the dashboard.
3. Save your **public API key**, which you'll use to initialize the JavaScript agent.
4. Create and securely store a **secret API key** for your server. Never expose it on the client side. You'll use this key on the backend to retrieve full visitor information through the Fingerprint Server API.

## 2. Set up your project

1. Clone or download the [starter repository](https://github.com/fingerprintjs/use-case-tutorials) and open it in your editor.

```bash Terminal theme={"theme":"github-dark-dimmed"}
git clone https://github.com/fingerprintjs/use-case-tutorials.git
```

2. This tutorial will be using the `paywall` folder. The project is organized as follows:

<Tree>
  <Tree.Folder name="public" defaultOpen>
    <Tree.File name="article.html - Article page" />

    <Tree.File name="article.js - Front-end logic to display articles" />

    <Tree.File name="index.html - News homepage" />

    <Tree.File name="index.js - Front-end logic to display article summaries" />
  </Tree.Folder>

  <Tree.Folder name="server" defaultOpen>
    <Tree.File name="articlesApi.js - Paywall and article logic" />

    <Tree.File name="db.js - SQLite database connection" />

    <Tree.File name="server.js - Serves static files and article endpoints" />
  </Tree.Folder>

  <Tree.File name=".env.example - Example environment variables" />
</Tree>

3. Install dependencies:

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm install
```

4. Copy or rename `.env.example` to `.env`, then add your Fingerprint API keys:

```bash Terminal theme={"theme":"github-dark-dimmed"}
FP_PUBLIC_API_KEY=your-public-key
FP_SECRET_API_KEY=your-secret-key
```

5. Start the server:

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm run dev
```

6. Visit [http://localhost:3000](http://localhost:3000) to view the mock news homepage. Click three different articles to read them. Notice that you cannot read any new articles after that.
7. The demo stores articles read data in localStorage to enforce the paywall. To reset the demo, either click **Reset demo** at the bottom of the page or manually clear the browser storage (e.g., open DevTools → Application → Local Storage → delete the site entry, or run `localStorage.clear()` in the console), then reload and read more articles. You'll see the paywall is easily bypassed when client-side storage is cleared.

## 3. Add Fingerprint to the frontend

In this step, you'll load the JavaScript agent when the page loads and trigger identification when the user goes to read an article. The JavaScript agent returns both a `visitorId` and a `requestId`. The visitor ID can be used to identify a browser or device uniquely and can be used to enforce the paywall. Instead of relying on the `visitorId` returned on the client side directly, you'll send the `requestId` to your server along with the request for the article content. The server will then call the [Fingerprint Events API](/reference/v3/server-api-get-event) to securely retrieve the full identification details, including the visitor ID, bot detection, and other signals.

1. Before adding any new code, let's delete all the client-side paywall logic and localStorage tracking. Specifically, open the `public/article.js` file and remove the `FREE_ARTICLES_LIMIT` constant and these functions: `getArticlesRead()`, `updateFreeCount()`, and `updateArticlesRead()`.
2. In `getArticle()`, delete the block that checks the number of articles read and toggles the paywall. Keep only the line that gets the `articleId` and everything after the `fetch` call.

```javascript public/article.js theme={"theme":"github-dark-dimmed"}
async function getArticle() {
  const articleId = window.location.pathname.split("/").pop();

  try {
    const response = await fetch(`/api/article/${articleId}`);

    const data = await response.json();
    const article = data.article;

  // ...
}
```

3. At the bottom of the file, remove the calls to `updateArticlesRead()` and `updateFreeCount()`, leaving only the `getArticle()` call.
4. At the top of `public/article.js`, load the JavaScript agent:

```javascript public/article.js theme={"theme":"github-dark-dimmed"}
const fpPromise = import(`https://fpjscdn.net/v3/${window.FP_PUBLIC_API_KEY}`).then(
  (FingerprintJS) => FingerprintJS.load({ region: "us" }),
);
```

5. Make sure to change `region` to match your workspace region (e.g., `eu` for Europe, `ap` for Asia, `us` for Global (default)).
6. Inside the `getArticle()` function, request visitor identification from Fingerprint using the `get()` method. Then, update the `fetch` call to use a `POST` request and include the returned `requestId` in the body when sending the article request to the server. (A matching `POST` endpoint is already set up for this tutorial.)

```javascript public/article.js theme={"theme":"github-dark-dimmed"}
async function getArticle() {
  const articleId = window.location.pathname.split("/").pop();

  try {
    const fp = await fpPromise;
    const { requestId } = await fp.get();

    const response = await fetch(`/api/article/${articleId}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ requestId }),
    });
    const data = await response.json();

    // ...
}
```

The `get()` method sends signals collected from the browser to Fingerprint servers, where they are analyzed to identify the visitor. The returned `requestId` acts as a reference to this specific identification event, which your server can later use to fetch the full visitor details.

For lower latency in production, use [Sealed Client Results](/docs/v3/sealed-client-results) to return full identification details as an encrypted payload from the `get()` method.

1. Update the "free articles remaining" message using the value returned from the server. (This won't work yet — it will be connected in the next steps.)

```javascript public/article.js theme={"theme":"github-dark-dimmed"}
async function getArticle() {
  // ...

    const data = await response.json();

    const articlesRemaining = data.articlesRemaining;
    let text = `${articlesRemaining} free articles remaining`;
    if (articlesRemaining == 0)
      text = "You have reached your free article limit";
    freeCountEl.textContent = text;

    if (!data.success) { ... }

  // ...
}
```

## 4. Receive and use the request ID to get visitor insights

Next, pass the `requestId` through to your backend logic, initialize the [Fingerprint Node Server SDK](/reference/node-server-sdk), and fetch the full visitor identification event so you can access the trusted `visitorId` and [Smart Signals](https://fingerprint.com/products/smart-signals/).

1. In the backend, the `server/server.js` file already defines API routes for the app. Update the `/api/article/:id` `POST` route to also extract `requestId` from the request body and pass it into the `getArticle` function. (You can delete the `/api/article/:id` GET route.)

```javascript server/server.js theme={"theme":"github-dark-dimmed"}
app.post("/api/article/:id", async (req, reply) => {
  const article = await getArticle(req.params.id, req.body.requestId);
  return reply.send(article);
});
```

2. The `server/articlesApi.js` file contains the logic for fetching articles. Start by importing and initializing the Fingerprint Node Server SDK there, and load your environment variables with `dotenv`.

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
import fs from "fs";
import { db } from "./db.js";
import { config } from "dotenv";
import { FingerprintJsServerApiClient, Region } from "@fingerprintjs/fingerprintjs-pro-server-api";

config();

const fpServerApiClient = new FingerprintJsServerApiClient({
  apiKey: process.env.FP_SECRET_API_KEY,
  region: Region.Global,
});
```

3. Make sure to change `region` to match your workspace region (e.g., `EU` for Europe, `AP` for Asia, `Global` for Global (default)).
4. Update the `getArticle` function to also accept `requestId` and use it to fetch the full identification event details from Fingerprint:

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
export async function getArticle(articleId, requestId) {
  const event = await fpServerApiClient.getEvent(requestId);

  // Simple article retrieval for demo
  const articles = JSON.parse(fs.readFileSync("./server/data/articles.json", "utf-8"));

  const article = articles.find((a) => a.id === Number(articleId));

  // ...
}
```

Using the `requestId`, the getEvent will retrieve the full data for the visitor identification request. The returned object will contain the visitor ID, IP address, device, and browser details, and Smart Signals like bot detection, browser tampering detection, VPN detection, and more.

You can see a full example of the event structure and test it with your own device in the [demo playground](https://demo.fingerprint.com/playground).

For additional checks to ensure the validity of the data coming from your frontend, view [how to protect from client-side tampering and replay attacks](/docs/v3/protecting-from-client-side-tampering).

## 5. Enforce paywall limits by visitor ID

Next, use the trusted `visitorId` to see how many free articles a visitor has read and enforce paywall limits. This ensures that even if someone clears cookies, uses incognito mode, or switches networks, their access is still tied to the same browser or device.

Note: The starter app includes a SQLite database with the following table already created for you:

```text SQLite database tables theme={"theme":"github-dark-dimmed"}
articles_read - Stores which articles each visitor has accessed
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  visitorId TEXT NOT NULL,
  articleId TEXT NOT NULL,
  createdAt INTEGER NOT NULL
```

1. Add some helper functions to the bottom of the `server/articlesApi.js` file to record and check articles read by the visitor:

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
// Record an article view (only if it hasn't been read before)
function recordArticleRead(visitorId, articleId) {
  const exists = db
    .prepare(`SELECT 1 FROM articles_read WHERE visitorId = ? AND articleId = ? LIMIT 1`)
    .get(visitorId, articleId);

  if (!exists) {
    db.prepare(`INSERT INTO articles_read (visitorId, articleId, createdAt) VALUES (?, ?, ?)`).run(
      visitorId,
      articleId,
      Date.now(),
    );
  }
}

// Get all unique articles read by a visitor
function getArticlesRead(visitorId) {
  const rows = db
    .prepare(
      `SELECT DISTINCT articleId 
       FROM articles_read 
       WHERE visitorId = ? 
       ORDER BY createdAt ASC`,
    )
    .all(visitorId);
  return rows.map((r) => r.articleId);
}
```

2. Update `getArticle` to retrieve the `visitorId`, from the `event` object and use it to determine how many articles the visitor has read using `getArticlesRead`. Make sure to include `articlesRemaining` in the responses as well:

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
export async function getArticle(articleId, requestId) {
  const FREE_ARTICLES_LIMIT = 3;

  const event = await fpServerApiClient.getEvent(requestId);
  const visitorId = event.products?.identification?.data?.visitorId || "";

  const articlesRead = getArticlesRead(visitorId);
  const articlesRemaining = Math.max(0, FREE_ARTICLES_LIMIT - articlesRead.length);

  // Simple article retrieval for demo
  const articles = JSON.parse(fs.readFileSync("./server/data/articles.json", "utf-8"));
  const article = articles.find((a) => a.id === Number(articleId));
  if (!article) {
    console.error("Article not found");
    return {
      success: false,
      message: "Article not found",
      articlesRemaining,
    };
  }

  // ...
}
```

3. Then add new paywall logic to check if the visitor can read the article. If so, log the article as being read and return its contents, otherwise block the article request:

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
export async function getArticle(articleId, requestId) {
  // ...

  if (!article) {
    console.error("Article not found");
    return {
      success: false,
      message: "Article not found",
      articlesRemaining,
    };
  }

  // Paywall check
  if (articlesRemaining <= 0 && !articlesRead.includes(articleId)) {
    console.error("No more free articles.");
    return {
      success: false,
      message: "You have reached the maximum number of free articles. Subscribe today!",
      articlesRemaining,
    };
  }

  // Record read and return updated remaining count
  recordArticleRead(visitorId, articleId);
  return {
    success: true,
    article,
    articlesRemaining: Math.max(0, articlesRemaining - 1),
  };
}
```

## 6. Block bots and suspicious devices

<Note>
  This optional step uses the Bot Detection and Suspect Score Smart Signals, which are only
  available on paid plans.
</Note>

A simple but effective way to protect your paywalled content is to block automated scraping attempts. The `event` object also includes the [Bot Detection Smart Signal](https://fingerprint.com/products/bot-detection/), which identifies automated activity so you can prevent bots from accessing your articles.

This signal returns `good` for known bots like search engines, `bad` for automation tools, headless browsers, or other signs of scraping, and `notDetected` when no bot activity is found.

1. Continuing in the `getArticle` function in `server/articlesApi.js`, check the bot signal in the `event` object and block requests from bots:

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
export async function getArticle(articleId, requestId) {
  // ...

  if (!article) { ... }

  // Check for bot activity
  const botDetected = event.products?.botd?.data?.bot?.result !== "notDetected";
  if (botDetected) {
    console.error("Bot detected.");
    return {
      success: false,
      message: "Article not found.",
      articlesRemaining,
    };
  }

  // ...
}
```

You can also add [Suspect Score](/docs/v3/suspect-score) as an additional layer. The Suspect Score is a weighted representation of all Smart Signals in the identification payload, helping to identify suspicious activity. While you wouldn't normally block article views based only on a high score, you could use it to flag suspicious visitors, limit access, or trigger additional verification.

2. Below the bot detection check, add a condition that reads the Suspect Score from the `event` object and blocks the article request if it exceeds a chosen threshold (for example, 20):

```javascript server/articlesApi.js theme={"theme":"github-dark-dimmed"}
export async function getArticle(articleId, requestId) {
  // ...

  // Check for bot activity
  const botDetected = event.products?.botd?.data?.bot?.result !== "notDetected";
  if (botDetected) {
    console.error("Bot detected.");
    return {
      success: false,
      message: "Article not found.",
      articlesRemaining,
    };
  }

  // Check for a high suspect score
  const suspectScore = event.products?.suspectScore?.data?.result || 0;
  if (suspectScore > 20) {
    console.error(`High Suspect Score detected: ${suspectScore}`);
    return {
      success: false,
      message: "Article not found.",
      articlesRemaining,
    };
  }

  // ...
}
```

Together with the visitor ID and bot detection Smart Signal, Fingerprint allows you to protect your content and enforce paywalls that can't be bypassed with cookies or incognito mode. You can extend this by analyzing additional signals, adjusting access limits, or customizing how you handle suspicious visitors.

<Info>
  This is a minimal example to show how to implement Fingerprint. In a real application, make sure
  to apply proper security practices, error handling, and paywall logic that align with your
  production standards.
</Info>

## 7. Test your implementation

Now that everything is wired up, you can test the full protected paywall flow.

1. Start your server if it isn't already running and open [http://localhost:3000](http://localhost:3000):

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm run dev
```

2. Read three different articles and on your fourth unique article you should hit the paywall.
3. Open a private browser window, visit the site, and try to read another new article beyond the limit. You will still be paywalled.
4. Bonus: Test the flow using a headless browser or automation tool to see bot detection in action. A sample script is available in `test-bot.js`. While your app is running, run the script with `node test-bot.js` in your terminal and observe that the automated article requests are denied.

```bash Terminal theme={"theme":"github-dark-dimmed"}
node test-bot.js
```

## Next steps

You now have a working paywall flow that enforces article limits and blocks scraping bots with Fingerprint. From here, you can expand the logic with more [Smart Signals](/docs/v3/smart-signals-reference), fine-tune access rules based on your content strategy, or add extra defenses like rate limiting or dynamic content gating.

To dive deeper, explore the other use case tutorials for more step-by-step examples.

Check out these related resources:

* [Node SDK Reference](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk)
* [Vue frontend quickstart](/docs/v3/vue-quickstart)
* [React frontend quickstart](/docs/v3/react-quickstart)
* [API reference for the Events endpoint](/reference/v3/server-api-get-event)
* [Use case tutorial: Detecting new account fraud](/docs/v3/new-account-fraud-use-case-tutorial)
* [Low-latency identification with Sealed Client Results](/docs/v3/sealed-client-results)
