Securing Ghost Magic Links
How I protect Ghost’s Magic Link URL from bots using Cloudflare Turnstile, Nginx, njs, and short-lived single-use tickets.
0. What happened?
During reputation and greylisting checks, I noticed that my mail server was occasionally being flagged. Analysis showed that the cause was not a compromised mailbox: bots were abusing the public Ghost endpoint that sends Magic Links for sign-in and registration. Every automated request can trigger a real email—and therefore burden delivery reputation, recipients, and infrastructure.
Ghost uses Magic Links for sign-in and registration. A visitor enters their email address, and Ghost then sends a message containing a single-use 0. What happened? login link.
This mechanism can be addressed automatically. A bot neither needs to take over a mailbox nor have a Ghost account. It can simply send repeated requests to the Magic Link endpoint, causing Ghost to send real emails.
This can have several consequences:
- unnecessarily high volumes of outgoing email,
- load on the SMTP server,
- a poorer sender and domain reputation,
- nuisance for third-party recipients,
- consumption of sending quotas,
- additional load on Ghost and the mail infrastructure.
Ghost already has its own protection mechanism for this flow. With verifyRequestIntegrity, Ghost can require a client to first obtain a short-lived integrity token and include it with the subsequent Magic Link request. This is particularly effective against bots that call the mail-sending endpoint directly.
An integrity token, however, is not a CAPTCHA and does not prove that a human is interacting with the site. A more sophisticated bot can also automate this preceding Ghost endpoint. I therefore wanted to add an additional, server-side enforced Turnstile check directly in front of the mail-sending step. What I was still missing was a safeguard that reliably blocks automated requests while covering both regular theme forms and Ghost Portal.
A simple JavaScript patch for window.fetch is not enough. Ghost Portal runs in its own iframe context. Protection that observes only the JavaScript of the main page can therefore easily miss Portal.
My solution: put Cloudflare Turnstile directly in front of Magic Link delivery.

Nginx and njs validate the Turnstile challenge server-side. Only after successful validation does the browser receive a short-lived single-use ticket. Only a Magic Link request with this ticket is allowed to reach Ghost.
Ghost itself remains unchanged.
This post describes my technical concept, the specific integration, and my experiences during the rollout. It is not a guarantee that the solution will be technically, legally, or privacy-compliant in every environment. In particular, review privacy, proxy configuration, logging, and request handling for your own infrastructure.
1. Architecture and protection principle
1.1 What exactly is protected?
The actual security boundary lies in front of this Ghost endpoint:
/members/api/send-magic-link
A POST to this endpoint reaches Ghost only if a valid Turnstile check was completed immediately beforehand.
Other areas of the website remain outside this additional barrier, including:
- standard pages,
- Ghost Admin,
- Content API,
- RSS,
- sitemaps,
- comments,
- ActivityPub,
- WebFinger.
This limits the additional protection layer to the part of Ghost that can actually trigger an email.

1.2 Why is a CAPTCHA in the form not enough?
Merely placing a CAPTCHA visibly in front of a form would not provide an adequate security boundary.
An attacker could bypass the form and call the Magic Link endpoint directly.
The decision whether the request reaches Ghost must therefore be made server-side.
Ghost Portal is another consideration: Portal runs in its own iframe context. A simple patch of window.fetch in the main window does not reliably capture its requests.
The solution therefore consists of two parts:
Browser side
The browser:
- detects a sign-in or registration,
- starts Turnstile,
- sends the Turnstile token to my own validation endpoint,
- receives a short-lived ticket after successful validation,
- then performs the originally requested sign-in or registration.
Server side
Nginx and njs:
- validate the Turnstile token with Cloudflare,
- create a single-use ticket,
- store that ticket server-side for a short time,
- validate it with the Magic Link request,
- consume it on the first successful access.
Without a valid ticket, the request does not reach Ghost.
1.3 Why a single-use ticket?
Cloudflare Turnstile provides a token after a successful challenge.
This token is validated server-side via Cloudflare Siteverify. Rather than directly forwarding a specific Ghost form request afterwards, I exchange it for my own short-lived, single-use ticket.
The flow:
- The browser runs Turnstile with the action
ghost_magic_link. - The Turnstile token is sent to
/_ghost-turnstile/redeem. - njs sends it to Cloudflare Siteverify server-side.
- Only
success=true, the expected hostname, and the expected action are accepted. - njs creates a random UUID.
- This UUID is stored server-side for no more than 120 seconds.
- The browser receives it as an
HttpOnly,Secure,SameSite=Laxcookie. - Nginx validates the ticket on the Magic Link request.
- The ticket is atomically removed on the first successful access.
- Replaying the same ticket results in
403.
A successful Turnstile check therefore authorises exactly one Magic Link delivery.
1.4 Flow at a glance
Browser
|
| Registration
v
Cloudflare Turnstile
|
| Challenge successful
v
/_ghost-turnstile/redeem
|
| serversided check
v
Cloudflare Siteverify
|
| Hostname + Action + success valid
v
njs creates onetime ticket
|
| Cookie, max 120sec valid
v
Browser
|
| POST /members/api/send-magic-link
v
Nginx checks and invalidates ticket
|
v
Ghost
|
v
MailserverGhost’s own Integrity checks and rate limits remain active. Turnstile is an additional layer in front of them.

2. Prerequisites
2.1 Example environment for this guide
All examples consistently use the following placeholders:
- public domain:
www.example.com - Ghost directory:
/var/www/ghost - Ghost upstream:
http://127.0.0.1:2368 - Project and helper scripts:
/opt/ghost-turnstile - njs handler:
/etc/nginx/njs/ghost_turnstile.js - Turnstile configuration:
/etc/nginx/ghost-turnstile-secret.json - global Nginx configuration:
/etc/nginx/snippets/ghost-turnstile-http.conf - vHost configuration:
/etc/nginx/snippets/ghost-turnstile-server.conf - browser adapter:
/var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js
You need to adapt these values to your environment.
2.2 What I tested with
My implementation was developed and tested in production with the following environment:
- Ghost 6.52.1,
- additional compatibility testing with Ghost 6.62.0,
- Ubuntu 24.04 LTS standard — Nginx 1.24.0 with
--with-compat, - Nginx module
http_auth_request, - self-compiled: njs 1.0.1,
- Node.js 22,
- Linux with systemd,
- Ghost Members,
- Ghost Portal,
- theme forms with
data-members-form="subscribe"anddata-members-form="signin".
My theme is based on Ghost Taste. However, the theme itself is not essential to this solution.
2.3 What you need
For installation, you need:
- a working Ghost installation,
- root or equivalent administrative access,
- Nginx as a reverse proxy in front of Ghost,
- a working HTTPS configuration,
- the Nginx module
http_auth_request, - njs in a version compatible with the installed Nginx,
- outbound HTTPS access to
challenges.cloudflare.com, - Ghost Members,
- Ghost Portal and/or Members forms in the theme,
- access to global Ghost Header Code Injection,
- a Cloudflare account with Turnstile.
If you build njs yourself as a dynamic module, you also need, for example:
- GCC,
- make,
- binutils,
- libssl-dev,
- libpcre2-dev,
- zlib1g-dev,
- the njs source code.
Playwright is useful, but not essential, for automated browser tests.
2.4 Requirements for the theme and Ghost Portal
Theme forms should use the attributes provided by Ghost, for example:
data-members-form="subscribe"
or:
data-members-form="signin"
Ghost Portal is a little more specific.
In the version I tested, Portal appears as an iframe from the same website. This allows my browser adapter to access its contents.
Among other things, it detects Portal through:
title="portal-popup"
and searches within it for sign-in or signup elements.
This DOM structure is not an interface whose long-term stability I would rely on.
After Ghost or Portal updates, you should therefore test whether the adapter still works.
3. Implementation
Step 1: Create a backup
Before making changes to a production Ghost or Nginx installation: Create a backup.
Back up at least:
- the active Nginx configuration,
- Ghost configuration,
- Code Injection,
- all files you intend to modify or create.
Also check how you will return to the previous state in case of an error.
A specific backup and restore strategy is not part of this guide.
Step 2: Create Cloudflare Turnstile
Create a widget
In the Cloudflare dashboard:
- Open Turnstile.
- Create a new widget.
- Set Widget Mode to "Managed".
- Enter the canonical production host, here
www.example.com. - Redirect other hostnames such as
example.comto the canonical host where applicable.
Sitekey and Secret
Cloudflare provides two values:
- Sitekey: public, used in the browser.
- Secret: confidential, remains exclusively on the server.
Pitfall: On my first attempt, the widget remained invisible because the dashboard configuration did not match the expected display. After switching to Managed the challenge appeared as intended.
The browser option appearance: 'interaction-only' controls only when an interactive widget becomes visible. It does not replace widget configuration in the Cloudflare dashboard.
Step 3: Provide a suitable njs version
On my system, the distribution provided an older njs version.
For a new production rollout, I did not want to use it because security advisories had since been published. I therefore built njs 1.0.1 as a dynamic module compatible with the already installed Nginx 1.24.0.
Why not simply copy a ready-made module?
A dynamic Nginx module must match the installed Nginx build and its ABI.
A binary module from another server should therefore not be adopted without verification.
Build script
File: /opt/ghost-turnstile/build-njs-module.sh
#!/bin/sh
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Purpose:
# Example build script for compiling the njs HTTP module against a matching
# Nginx source tree.
#
# Disclaimer:
# Example implementation without warranty. Review versions, checksums,
# compiler options and paths before using this on a production system.
# A dynamic module must match the Nginx build it is loaded into.
# -----------------------------------------------------------------------------
set -eu
project=/opt/ghost-turnstile
nginx_version=1.24.0
njs_version=1.0.1
nginx_archive="$project/build/sources/nginx-$nginx_version.tar.gz"
njs_archive="$project/build/sources/njs-$njs_version.tar.gz"
output="$project/build/output/ngx_http_js_module_$njs_version.so"
nginx_sha256=77a2541637b92a621e3ee76776c8b7b40cf6d707e69ba53a940283e30ff2f55d
njs_sha256=74372cfcbf11eb0a71bc555e19dc785f61d561d5663d254474da6c8c9e50a6a7
# Check required build tools.
for command in nginx gcc make tar sha256sum mktemp install; do
command -v "$command" >/dev/null 2>&1 || {
printf '%s\n' "Missing build command: $command" >&2
exit 1
}
done
# Verify that the script is being used against the expected Nginx version.
installed_version=$(nginx -v 2>&1)
installed_version=${installed_version#nginx version: nginx/}
if [ "$installed_version" != "$nginx_version" ]; then
printf '%s\n' \
"Refusing ABI build: installed Nginx is $installed_version, expected $nginx_version." >&2
exit 1
fi
# The documented build expects Nginx to provide --with-compat.
nginx -V 2>&1 | grep -q -- '--with-compat' || {
printf '%s\n' 'Refusing ABI build: installed Nginx lacks --with-compat.' >&2
exit 1
}
check_hash() {
file=$1
expected=$2
[ -f "$file" ] || {
printf '%s\n' "Missing source archive: $file" >&2
exit 1
}
actual=$(sha256sum "$file")
actual=${actual%% *}
[ "$actual" = "$expected" ] || {
printf '%s\n' "Checksum mismatch: $file" >&2
exit 1
}
}
check_hash "$nginx_archive" "$nginx_sha256"
check_hash "$njs_archive" "$njs_sha256"
# Build in a temporary directory and clean it up afterwards.
build_root=$(mktemp -d /tmp/ghost-turnstile-build.XXXXXX)
cleanup() {
case "$build_root" in
/tmp/ghost-turnstile-build.*)
rm -rf -- "$build_root"
;;
*)
printf '%s\n' 'Refusing unexpected cleanup path.' >&2
;;
esac
}
trap cleanup EXIT HUP INT TERM
tar -xzf "$nginx_archive" -C "$build_root"
tar -xzf "$njs_archive" -C "$build_root"
nginx_source="$build_root/nginx-$nginx_version"
njs_source="$build_root/njs-$njs_version"
(
cd "$nginx_source"
# Disable optional components not required for this use case.
NJS_LIBXSLT=NO \
NJS_ZLIB=NO \
NJS_QUICKJS=NO \
./configure \
--with-compat \
--add-dynamic-module="$njs_source/nginx"
make -j2 modules
)
mkdir -p "$(dirname "$output")"
install -m 0644 \
"$nginx_source/objs/ngx_http_js_module.so" \
"$output"
# Print the resulting checksum for documentation.
sha256sum "$output"
printf '%s\n' 'Build completed.'Load module
After the build, the module is installed, for example, in the following location:
/usr/local/lib/nginx/modules/ngx_http_js_module_1.0.1.so
File: /etc/nginx/modules-enabled/50-ghost-turnstile-njs.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Disclaimer:
# Example configuration without warranty.
# The module path and ABI compatibility must match the installed Nginx build.
# -----------------------------------------------------------------------------
# Load the custom-built njs HTTP module once.
load_module /usr/local/lib/nginx/modules/ngx_http_js_module_1.0.1.so;Then check nginx:
nginx -t
Step 4: Install the Turnstile secret
The Turnstile secret does not belong:
- in Git,
- in shell arguments,
- in chat histories,
- in logs,
- in publicly readable configuration files.
I store the server-side configuration in:
/etc/nginx/ghost-turnstile-secret.json
To ensure secrets do not end up in any history and that files are created securely, I use a helper script. You can of course also do this manually.
Installation script
File: /opt/ghost-turnstile/install-secret.sh
#!/bin/sh
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Purpose:
# Install the Cloudflare Turnstile secret without exposing it through command
# line arguments or normal terminal output.
#
# Disclaimer:
# Example implementation without warranty.
# Review hostname, file paths and secret handling for your environment.
# -----------------------------------------------------------------------------
set -eu
target=/etc/nginx/ghost-turnstile-secret.json
# Make newly created files private by default.
umask 077
temporary=
terminal_state=
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' \
'Run this script with sudo; the secret is read silently from the controlling terminal.' >&2
exit 1
fi
cleanup() {
secret=
# Restore terminal settings if execution was interrupted.
if [ -n "$terminal_state" ]; then
stty "$terminal_state" </dev/tty
fi
[ -z "$temporary" ] || [ ! -e "$temporary" ] || rm -f -- "$temporary"
}
trap cleanup EXIT HUP INT TERM
printf '%s' 'Turnstile production secret (input hidden): ' >/dev/tty
terminal_state=$(stty -g </dev/tty)
stty -echo </dev/tty
IFS= read -r secret </dev/tty
stty "$terminal_state" </dev/tty
terminal_state=
printf '\n' >/dev/tty
# Reject clearly malformed values.
case "$secret" in
''|*[!A-Za-z0-9_-]*)
secret=
printf '%s\n' 'Rejected: unexpected secret format.' >&2
exit 1
;;
esac
if [ "${#secret}" -lt 20 ] || [ "${#secret}" -gt 128 ]; then
secret=
printf '%s\n' 'Rejected: unexpected secret length.' >&2
exit 1
fi
# Create the new configuration atomically.
temporary=$(mktemp /etc/nginx/.ghost-turnstile-secret.XXXXXX)
printf '%s\n' \
"{\"secret\":\"$secret\",\"hostnames\":[\"www.example.com\"],\"origin\":\"https://www.example.com\",\"action\":\"ghost_magic_link\",\"ticketTtlSeconds\":120}" \
>"$temporary"
secret=
chown root:root "$temporary"
chmod 600 "$temporary"
mv -f -- "$temporary" "$target"
trap - EXIT HUP INT TERM
temporary=
printf '%s\n' \
"Installed $target as root:root mode 0600; no value was echoed."Run:
sudo /opt/ghost-turnstile/install-secret.sh
The generated configuration contains:
- Secret,
- allowed hostname,
- expected origin,
- expected action,
- ticket validity period.
Step 5: Install the njs handler
The njs handler performs the actual server-side logic:
- validate the Turnstile token via Siteverify,
- validate hostname and action,
- create a single-use ticket,
- consume the ticket later with the Ghost request.
File: /etc/nginx/njs/ghost_turnstile.js
/*
* -----------------------------------------------------------------------------
* Ghost Turnstile Protection for Ghost CMS
*
* Author / Copyright:
* initinsights.de
*
* Purpose:
* Server-side Cloudflare Turnstile validation and one-time authorization
* tickets for Ghost's Magic-Link endpoint.
*
* Disclaimer:
* Example implementation without warranty.
* Review security assumptions, Nginx/njs compatibility, hostname policy,
* logging and proxy configuration before production use.
* -----------------------------------------------------------------------------
*/
const REDEEM_PATH = '/_ghost-turnstile/siteverify';
const COOKIE_NAME = '__Secure-ghost_turnstile';
const TICKET_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const JSON_TYPE =
/^application\/json(?:\s*;\s*charset=utf-8)?$/i;
/*
* Validate the preloaded server-side configuration.
* Unexpected values cause a fail-closed response.
*/
function config() {
const value = globalThis.ghost_turnstile_config;
if (!value ||
typeof value.secret !== 'string' ||
value.secret.length < 20) {
throw new Error('invalid Turnstile configuration');
}
if (!Array.isArray(value.hostnames) ||
value.hostnames.length === 0) {
throw new Error('empty Turnstile hostname allowlist');
}
if (typeof value.origin !== 'string' ||
value.origin !== `https://${value.hostnames[0]}`) {
throw new Error('invalid Turnstile origin');
}
if (value.action !== 'ghost_magic_link' ||
value.ticketTtlSeconds !== 120) {
throw new Error('invalid Turnstile policy configuration');
}
return value;
}
function setCommonHeaders(r) {
r.headersOut['Cache-Control'] = 'no-store';
r.headersOut['Content-Type'] = 'application/json; charset=utf-8';
r.headersOut['X-Content-Type-Options'] = 'nosniff';
}
/*
* Only return fixed error codes.
* Do not reflect secrets, tokens or user-supplied values.
*/
function reject(r, status, code) {
setCommonHeaders(r);
r.return(status, JSON.stringify({
ok: false,
code
}));
}
function fixedWarning(r, event) {
r.warn(`ghost_turnstile event=${event}`);
}
function hostnameAllowed(hostnames, hostname) {
for (let index = 0; index < hostnames.length; index += 1) {
if (hostnames[index] === hostname) {
return true;
}
}
return false;
}
function parseCookies(header) {
const result = Object.create(null);
if (typeof header !== 'string') {
return result;
}
const parts = header.split(';');
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const separator = part.indexOf('=');
if (separator < 1) {
continue;
}
const name = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (!(name in result)) {
result[name] = value;
}
}
return result;
}
/*
* Create and store a short-lived one-time ticket.
*/
function issueTicket(r, cfg) {
const tickets =
globalThis.ngx.shared.ghost_turnstile_tickets;
for (let attempt = 0; attempt < 3; attempt += 1) {
const ticket = globalThis.crypto.randomUUID();
if (!TICKET_PATTERN.test(ticket) ||
!tickets.add(ticket, '1')) {
continue;
}
r.headersOut['Set-Cookie'] =
`${COOKIE_NAME}=${ticket}; ` +
`Max-Age=${cfg.ticketTtlSeconds}; ` +
'Path=/members/api/send-magic-link; ' +
'HttpOnly; Secure; SameSite=Lax';
setCommonHeaders(r);
r.return(204);
return;
}
fixedWarning(r, 'ticket_allocation_failed');
reject(r, 503, 'temporarily_unavailable');
}
/*
* Validate a Cloudflare Turnstile token and exchange it for a local ticket.
*/
async function redeem(r) {
let cfg;
try {
cfg = config();
} catch (_) {
fixedWarning(r, 'configuration_invalid');
reject(r, 503, 'temporarily_unavailable');
return;
}
if (r.method !== 'POST') {
reject(r, 405, 'method_not_allowed');
return;
}
if (r.headersIn.Origin !== cfg.origin) {
reject(r, 403, 'invalid_request');
return;
}
if (!JSON_TYPE.test(r.headersIn['Content-Type'] || '')) {
reject(r, 415, 'invalid_request');
return;
}
let body;
try {
body = await r.readRequestJSON();
} catch (_) {
reject(r, 400, 'invalid_request');
return;
}
const token = body && body.token;
if (typeof token !== 'string' ||
token.length === 0 ||
token.length > 2048) {
reject(r, 400, 'invalid_request');
return;
}
/*
* remoteip is deliberately omitted here.
*/
const form =
`secret=${encodeURIComponent(cfg.secret)}` +
`&response=${encodeURIComponent(token)}`;
let reply;
try {
reply = await r.subrequest(REDEEM_PATH, {
method: 'POST',
body: form
});
} catch (_) {
fixedWarning(r, 'siteverify_unavailable');
reject(r, 503, 'temporarily_unavailable');
return;
}
if (!reply ||
reply.status !== 200 ||
typeof reply.responseText !== 'string' ||
reply.responseText.length > 4096) {
fixedWarning(r, 'siteverify_invalid_transport');
reject(r, 503, 'temporarily_unavailable');
return;
}
let verification;
try {
verification = JSON.parse(reply.responseText);
} catch (_) {
fixedWarning(r, 'siteverify_invalid_json');
reject(r, 503, 'temporarily_unavailable');
return;
}
/*
* A generic success is not enough.
* Hostname and action must also match the configured policy.
*/
if (verification.success !== true ||
!hostnameAllowed(cfg.hostnames, verification.hostname) ||
verification.action !== cfg.action) {
reject(r, 403, 'verification_failed');
return;
}
issueTicket(r, cfg);
}
/*
* Called through Nginx auth_request.
* pop() consumes the ticket atomically.
*/
function authorize(r) {
const cookies = parseCookies(r.headersIn.Cookie);
const ticket = cookies[COOKIE_NAME];
if (typeof ticket !== 'string' ||
!TICKET_PATTERN.test(ticket)) {
r.return(403);
return;
}
const value =
globalThis.ngx.shared.ghost_turnstile_tickets.pop(ticket);
if (value !== '1') {
r.return(403);
return;
}
r.return(204);
}
export default {
authorize,
redeem
};What the handler accepts
The redemption endpoint intentionally accepts only:
- HTTP method:
POST, - exactly the expected
Origin, application/json,- a non-empty token of no more than 2048 characters,
- a limited Siteverify response.
Error responses contain only fixed error codes.
Step 6: Global Nginx configuration
Some directives belong exactly once in Nginx’s global http context.
File: /etc/nginx/snippets/ghost-turnstile-http.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Scope:
# Global configuration for the Nginx http context.
#
# Disclaimer:
# Example configuration without warranty.
# Review client-IP handling, rate limits, module compatibility and file paths
# for your environment before production use.
# -----------------------------------------------------------------------------
# Import the njs handler.
js_import ghost_turnstile
from /etc/nginx/njs/ghost_turnstile.js;
# Load the root-only Turnstile policy and secret.
js_preload_object ghost_turnstile_config
from /etc/nginx/ghost-turnstile-secret.json;
# Shared-memory storage for short-lived one-time tickets.
js_shared_dict_zone
zone=ghost_turnstile_tickets:1m
timeout=120s
evict;
# Limit Turnstile redemption attempts per client address.
limit_req_zone
$binary_remote_addr
zone=ghost_turnstile_redeem:1m
rate=6r/m;
# Additional rate limit for the actual Magic-Link endpoint.
limit_req_zone
$binary_remote_addr
zone=ghost_magic_link:1m
rate=3r/m;
# Bound the response exposed to njs by the internal Siteverify subrequest.
subrequest_output_buffer_size 8k;Include
The file is included once from the http context of /etc/nginx/nginx.conf as follows:
include /etc/nginx/snippets/ghost-turnstile-http.conf;
Take the client IP address into account
The rate limits here are based on $binary_remote_addr.
This is only useful if Nginx actually sees the trustworthy client IP address there.
If a CDN, reverse proxy, or load balancer sits in front of it, real_ip must first be configured correctly and securely against spoofing.
Step 7: Protect the Magic Link endpoint in the vHost
The actual access control belongs in the canonical HTTPS vHost.
File: /etc/nginx/snippets/ghost-turnstile-server.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Scope:
# Include this file ONLY in the canonical HTTPS server block for
# www.example.com.
#
# Example Ghost upstream:
# http://127.0.0.1:2368
#
# Disclaimer:
# Example configuration without warranty.
# Review hostname, Ghost upstream, proxy headers, rate limits and paths before
# production use.
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Browser adapter
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/client.js {
alias /var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js;
default_type application/javascript;
add_header Cache-Control "public, max-age=300" always;
add_header X-Content-Type-Options nosniff always;
access_log off;
}
# ---------------------------------------------------------------------------
# Turnstile token redemption
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/redeem {
client_max_body_size 4k;
client_body_buffer_size 4k;
limit_req zone=ghost_turnstile_redeem burst=3 nodelay;
limit_req_status 429;
limit_req_log_level notice;
js_content ghost_turnstile.redeem;
# The request contains a Turnstile token.
access_log off;
}
# ---------------------------------------------------------------------------
# Internal Cloudflare Siteverify proxy
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/siteverify {
internal;
proxy_pass
https://challenges.cloudflare.com/turnstile/v0/siteverify;
# Forward only the headers required by Siteverify.
proxy_pass_request_headers off;
proxy_set_header Host challenges.cloudflare.com;
proxy_set_header Content-Type application/x-www-form-urlencoded;
proxy_ssl_server_name on;
proxy_ssl_name challenges.cloudflare.com;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate
/etc/ssl/certs/ca-certificates.crt;
proxy_ssl_verify_depth 3;
proxy_connect_timeout 3s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
access_log off;
}
# ---------------------------------------------------------------------------
# Internal authorization handler
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/auth {
internal;
js_content ghost_turnstile.authorize;
access_log off;
}
# ---------------------------------------------------------------------------
# Ghost Magic-Link endpoint without trailing slash
# ---------------------------------------------------------------------------
location = /members/api/send-magic-link {
if ($request_method != POST) {
return 405;
}
client_max_body_size 128k;
limit_req zone=ghost_magic_link burst=2 nodelay;
limit_req_status 429;
limit_req_log_level notice;
# Ghost is reached only after successful ticket authorization.
auth_request /_ghost-turnstile/auth;
# Clear the short-lived browser cookie after use.
add_header Set-Cookie
"__Secure-ghost_turnstile=; Max-Age=0; Path=/members/api/send-magic-link; HttpOnly; Secure; SameSite=Lax"
always;
add_header X-Content-Type-Options nosniff always;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto
$scheme;
proxy_set_header X-Real-IP
$remote_addr;
proxy_set_header Host
$http_host;
proxy_pass http://127.0.0.1:2368;
}
# ---------------------------------------------------------------------------
# Ghost Magic-Link endpoint with trailing slash
# ---------------------------------------------------------------------------
location = /members/api/send-magic-link/ {
if ($request_method != POST) {
return 405;
}
client_max_body_size 128k;
limit_req zone=ghost_magic_link burst=2 nodelay;
limit_req_status 429;
limit_req_log_level notice;
auth_request /_ghost-turnstile/auth;
add_header Set-Cookie
"__Secure-ghost_turnstile=; Max-Age=0; Path=/members/api/send-magic-link; HttpOnly; Secure; SameSite=Lax"
always;
add_header X-Content-Type-Options nosniff always;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto
$scheme;
proxy_set_header X-Real-IP
$remote_addr;
proxy_set_header Host
$http_host;
proxy_pass http://127.0.0.1:2368;
}Why two Magic Link locations?
Clients can call the endpoint with or without a trailing slash:
/members/api/send-magic-link/members/api/send-magic-link/
I therefore explicitly protect both variants.
Include in the vHost
In the canonical HTTPS server block:
include /etc/nginx/snippets/ghost-turnstile-server.conf;
Then:
nginx -t
and only after a successful check:
systemctl reload nginx
Step 8: Install the browser adapter
The browser adapter initially stops sign-in and registration actions.
It runs Turnstile and continues the original action only after the server has issued a valid ticket.
I do not patch window.fetch globally.
Instead, specific events are intercepted:
submitfor theme forms,clickin Ghost Portal,Enterin Ghost Portal’s email field.
File in the Ghost directory: /var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js
/*
* -----------------------------------------------------------------------------
* Ghost Turnstile Protection for Ghost CMS
*
* Author / Copyright:
* initinsights.de
*
* Purpose:
* Browser adapter for Ghost Members theme forms and Ghost Portal.
* A valid one-time server ticket is acquired before the original action is
* replayed.
*
* Disclaimer:
* Example implementation without warranty.
* Ghost Portal uses internal DOM structures that may change after updates.
* Re-test Theme signup/signin and Portal signup/signin after Ghost updates.
* -----------------------------------------------------------------------------
*/
(function () {
'use strict';
const CONFIG_ID = 'ghost-turnstile-config';
const PORTAL_TITLE = 'portal-popup';
const SAFETY_MARGIN_MS = 5000;
const CHALLENGE_TIMEOUT_MS = 30000;
const bypass = new WeakSet();
let config;
let widgetId;
let inflight;
let resolveInflight;
let rejectInflight;
let challengeTimer;
let ticketExpiresAt = 0;
let ui;
function language() {
return (document.documentElement.lang || '')
.toLowerCase()
.startsWith('de')
? 'de'
: 'en';
}
function messages() {
return language() === 'de'
? {
checking: 'Sicherheitsprüfung wird vorbereitet …',
interactive: 'Bitte schließe die Sicherheitsprüfung ab.',
failed: 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.',
retry: 'Erneut versuchen'
}
: {
checking: 'Preparing security check …',
interactive: 'Please complete the security check.',
failed: 'The security check failed. Please try again.',
retry: 'Try again'
};
}
/*
* Read and validate the public configuration from Ghost Code Injection.
*/
function parseConfig() {
const element = document.getElementById(CONFIG_ID);
if (!element) {
return null;
}
try {
const value = JSON.parse(element.textContent || '{}');
if (value.hostname !== window.location.hostname ||
value.action !== 'ghost_magic_link' ||
typeof value.sitekey !== 'string' ||
!value.sitekey ||
value.redeemUrl !== '/_ghost-turnstile/redeem' ||
value.ticketTtlSeconds !== 120) {
return null;
}
return value;
} catch (_) {
return null;
}
}
/*
* Create the small status UI only when required.
*/
function createUi() {
const text = messages();
const style = document.createElement('style');
style.textContent =
'#ghost-turnstile-ui{' +
'position:fixed;' +
'right:1rem;' +
'bottom:1rem;' +
'z-index:2147483646;' +
'max-width:min(22rem,calc(100vw - 2rem));' +
'padding:.75rem;' +
'background:#fff;' +
'color:#241f1c;' +
'border:1px solid #76665d;' +
'border-radius:.5rem;' +
'box-shadow:0 .25rem 1.25rem rgba(0,0,0,.18);' +
'font:14px/1.4 system-ui,sans-serif' +
'}' +
'#ghost-turnstile-ui[hidden]{display:none}' +
'#ghost-turnstile-status{margin:.25rem 0 .5rem}' +
'#ghost-turnstile-retry{' +
'border:0;' +
'border-radius:999px;' +
'padding:.55rem .9rem;' +
'background:#c44f00;' +
'color:#fff;' +
'font:inherit;' +
'font-weight:700;' +
'cursor:pointer' +
'}' +
'#ghost-turnstile-widget{min-height:1px}';
document.head.appendChild(style);
const root = document.createElement('section');
root.id = 'ghost-turnstile-ui';
root.hidden = true;
root.setAttribute('role', 'status');
root.setAttribute('aria-live', 'polite');
const status = document.createElement('p');
status.id = 'ghost-turnstile-status';
status.textContent = text.checking;
const widget = document.createElement('div');
widget.id = 'ghost-turnstile-widget';
const retry = document.createElement('button');
retry.id = 'ghost-turnstile-retry';
retry.type = 'button';
retry.textContent = text.retry;
retry.hidden = true;
retry.addEventListener('click', function () {
ticketExpiresAt = 0;
acquireTicket().catch(function () {});
});
root.append(status, widget, retry);
document.body.appendChild(root);
return {
root,
status,
widget,
retry
};
}
function show(statusKey, canRetry) {
if (!ui) {
ui = createUi();
}
ui.status.textContent = messages()[statusKey];
ui.retry.hidden = !canRetry;
ui.root.hidden = false;
}
function hide() {
if (ui) {
ui.root.hidden = true;
}
}
/*
* Resolve or reject one running challenge.
*/
function settle(error) {
window.clearTimeout(challengeTimer);
const resolve = resolveInflight;
const reject = rejectInflight;
inflight = null;
resolveInflight = null;
rejectInflight = null;
if (error) {
ticketExpiresAt = 0;
show('failed', true);
if (reject) {
reject(error);
}
} else {
hide();
if (resolve) {
resolve();
}
}
}
/*
* Exchange the Cloudflare token for a server-side one-time ticket.
*/
async function redeem(token) {
try {
const response =
await fetch(config.redeemUrl, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token
})
});
if (!response.ok) {
throw new Error('verification failed');
}
ticketExpiresAt =
Date.now() +
config.ticketTtlSeconds * 1000;
settle();
} catch (error) {
settle(error);
}
}
function renderWidget() {
if (widgetId !== undefined ||
!window.turnstile) {
return;
}
if (!ui) {
ui = createUi();
}
widgetId =
window.turnstile.render(ui.widget, {
sitekey: config.sitekey,
action: config.action,
execution: 'execute',
appearance: 'interaction-only',
retry: 'never',
'refresh-expired': 'manual',
callback: redeem,
'before-interactive-callback': function () {
show('interactive', false);
},
'error-callback': function () {
settle(new Error('challenge error'));
},
'expired-callback': function () {
settle(new Error('challenge expired'));
},
'timeout-callback': function () {
settle(new Error('challenge timeout'));
}
});
}
/*
* Wait only for a bounded period for Cloudflare's client API.
*/
function waitForTurnstile(deadline) {
if (window.turnstile) {
renderWidget();
window.turnstile.reset(widgetId);
window.turnstile.execute(widgetId);
return;
}
if (Date.now() >= deadline) {
settle(new Error('challenge unavailable'));
return;
}
window.setTimeout(function () {
waitForTurnstile(deadline);
}, 100);
}
function acquireTicket() {
if (ticketExpiresAt - Date.now() >
SAFETY_MARGIN_MS) {
return Promise.resolve();
}
if (inflight) {
return inflight;
}
show('checking', false);
inflight =
new Promise(function (resolve, reject) {
resolveInflight = resolve;
rejectInflight = reject;
});
challengeTimer =
window.setTimeout(function () {
settle(new Error('challenge timeout'));
}, CHALLENGE_TIMEOUT_MS);
waitForTurnstile(Date.now() + 10000);
return inflight;
}
function consumeLocalTicket() {
ticketExpiresAt = 0;
}
/*
* Replay the original user action only after authorization succeeded.
*/
async function replay(target, mode) {
try {
await acquireTicket();
bypass.add(target);
if (mode === 'submit') {
target.requestSubmit();
} else {
target.click();
}
consumeLocalTicket();
} catch (_) {
// Fail closed: never submit without authorization.
}
}
/*
* Protect regular Ghost Members forms from the active theme.
*/
function gateThemeSubmit(event) {
const form = event.target;
if (!(form instanceof HTMLFormElement) ||
!form.matches(
'[data-members-form="subscribe"], ' +
'[data-members-form="signin"]'
)) {
return;
}
if (bypass.delete(form)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(form, 'submit');
}
/*
* Find the currently relevant submit button inside Ghost Portal.
*/
function portalEmailButton(doc, target) {
const wrapper =
doc.querySelector(
'.gh-portal-popup-wrapper.signin, ' +
'.gh-portal-popup-wrapper.signup'
);
const email =
doc.querySelector(
'input[type="email"][name="email"]'
);
if (!wrapper ||
!email ||
email.disabled) {
return null;
}
if (target && target.closest) {
const button =
target.closest('button[type="submit"]');
if (button) {
return button;
}
}
return doc.querySelector('button[type="submit"]');
}
/*
* Attach handlers directly inside the readable Ghost Portal iframe.
*/
function attachPortal(frame) {
if (frame.dataset.ghostTurnstileAttached === '1') {
return;
}
const doc = frame.contentDocument;
if (!doc ||
!doc.querySelector('.gh-portal-popup-wrapper')) {
return;
}
frame.dataset.ghostTurnstileAttached = '1';
doc.addEventListener(
'click',
function (event) {
const button =
portalEmailButton(
doc,
event.target
);
if (!button ||
bypass.delete(button)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(button, 'click');
},
true
);
doc.addEventListener(
'keydown',
function (event) {
if (event.key !== 'Enter' ||
event.isComposing) {
return;
}
const button =
portalEmailButton(
doc,
event.target
);
if (!button ||
event.target.type !== 'email') {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(button, 'click');
},
true
);
}
/*
* Ghost Portal is created dynamically.
*/
function discoverPortal() {
for (const frame of
document.querySelectorAll(
`iframe[title="${PORTAL_TITLE}"]`
)) {
try {
attachPortal(frame);
} catch (_) {
// Nginx remains the final security boundary.
}
}
}
/*
* Start Turnstile early when the user interacts with a relevant field.
*/
function prewarm(event) {
const target = event.target;
if (!target ||
!target.closest) {
return;
}
if (target.closest(
'[data-portal], ' +
'[data-members-form] input[type="email"]'
)) {
acquireTicket().catch(function () {});
}
}
function start() {
config = parseConfig();
if (!config) {
return;
}
document.addEventListener(
'submit',
gateThemeSubmit,
true
);
document.addEventListener(
'focusin',
prewarm,
true
);
document.addEventListener(
'pointerdown',
prewarm,
true
);
new MutationObserver(
discoverPortal
).observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
discoverPortal();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
start,
{
once: true
}
);
} else {
start();
}
})();Important point
The browser adapter provides a smooth user experience.
However, Nginx remains the actual security boundary.
Even if Ghost Portal changes after an update so that the browser adapter no longer works correctly, a direct unauthorised Magic Link request must still not reach Ghost.
Step 9: Add Ghost Code Injection
The public browser configuration goes into the global Ghost Header Code Injection.
Insertion location: Ghost Admin → Settings → Advanced → Code injection → Site Header
<!-- Turnstile Protection for Ghost CMS -->
<script id="ghost-turnstile-config" type="application/json">
{
"sitekey": "YOUR_PUBLIC_SITEKEY",
"hostname": "www.example.com",
"action": "ghost_magic_link",
"redeemUrl": "/_ghost-turnstile/redeem",
"ticketTtlSeconds": 120
}
</script>
<!-- Local browser adapter. -->
<script
defer
src="/_ghost-turnstile/client.js?v=ASSET_HASH">
</script>
<!-- Official Cloudflare Turnstile browser API. -->
<script
defer
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit">
</script>Adjust
YOUR_PUBLIC_SITEKEY is replaced with your public Turnstile sitekey.
ASSET_HASH serves only as a cache buster, for example a short prefix of the SHA-256 hash of the JavaScript file.
4. Tests before production use
The protection layer should not be assessed merely by whether a Turnstile widget appears somewhere.
What matters is that the protected endpoint cannot be bypassed and Ghost otherwise continues to work normally.
4.1 Check the Nginx configuration
First:
nginx -tOnly afterwards:
systemctl reload nginx4.2 Negative test: Direct Magic Link request
A request without a valid single-use ticket must be blocked.
Terminal:
# A direct request without a valid one-time ticket must not reach Ghost.
curl \
-o /dev/null \
-w "%{http_code}\n" \
-X POST \
-H "Content-Type: application/json" \
--data '{}' \
https://www.example.com/members/api/send-magic-linkExpected: HTTP return code 403
The same must apply to the variant with a trailing slash: /members/api/send-magic-link/
4.3 Test theme forms
Test at least:
Sign-in
- enter an email address,
- submit,
- Turnstile must be completed before the Ghost request,
- followed by exactly one Magic Link request.
Registration / Subscribe
- the same behaviour,
- no direct bypass of the Turnstile step.
If the site is multilingual, all language versions should be tested.
4.4 Test Ghost Portal
Portal should be tested separately.
My adapter currently expects, among other things:
iframe[title="portal-popup"].gh-portal-popup-wrapper.signin.gh-portal-popup-wrapper.signupinput[type="email"][name="email"]button[type="submit"]
The following cases should work:
- Portal signup by click,
- Portal sign-in by click,
- submission with Enter in the email field.
This test should be performed again after Ghost updates.
4.5 Test desktop and mobile
Turnstile may behave differently depending on the browser, screen size, and risk assessment.
I therefore test at least:
- desktop,
- mobile view,
- interactive challenge,
- non-interactive successful validation,
- error or retry case.
4.6 Test a real email delivery
Only when the previous tests have succeeded should you perform a real delivery test.
A designated test address is sufficient for this.
The aim is not to generate hundreds of test emails, but to confirm:
- Turnstile successful,
- ticket successful,
- Ghost receives exactly one request,
- Magic Link email arrives.
4.7 Check the rest of the website
I also check that other areas continue to work unchanged:
- homepage,
- Ghost Admin,
- Content API,
- RSS,
- sitemaps,
- comments,
- analytics,
- ActivityPub and WebFinger, if used,
- privacy pages.
4.8 Check logs
Logs should be reviewed after testing.
In particular, they should not contain:
- the Turnstile secret,
- full Turnstile tokens,
- ticket-cookie values,
- test email addresses,
- complete Siteverify payloads.
Fixed technical events such as these are useful instead:
configuration_invalidticket_allocation_failedsiteverify_unavailablesiteverify_invalid_transportsiteverify_invalid_json
5. What did not work immediately in my rollout
In my view, a production experience report is more useful when it does not only show the finished state.
The included njs version was too old for me
My distribution provided an older njs version.
For the new production deployment, I therefore built a newer version compatible with the existing Nginx.
The first build failed because dependencies were missing
Additional development libraries were required.
The default build options also attempted to include components that I did not need for this use case. I disabled them deliberately.
Ghost Admin API tokens could not modify Code Injection
Reading worked in my environment.
However, write access to the relevant settings was rejected. I therefore added the Code Injection manually through Ghost Admin.
The Turnstile widget was initially invisible
The cause was the widget configuration in the Cloudflare dashboard.
After switching to Managed the expected display worked.
My first mobile test made an incorrect assumption
The test expected a wider widget box than Cloudflare actually uses on small displays.
I therefore adapted the test to actual behaviour, not the other way around.
Existing problems must be considered separately
If Ghost or the server already occasionally shows timeouts or other anomalies before rollout, this should be documented.
Otherwise, there is a risk of later incorrectly attributing old problems to the new protection layer.
My rule for unexpected production issues:
Restore availability first, analyse the cause afterwards.
6. Privacy
Turnstile creates a direct connection between the visitor’s browser and Cloudflare.
This should be described transparently in the privacy policy.
At a minimum, I document:
- the purpose: protecting sign-in, registration, and Magic Link delivery from automated abuse,
- the direct connection to Cloudflare,
- possible technical connection, browser, device, and page data,
- server-side Siteverify validation,
- whether
remoteipis additionally transmitted, - the short-lived single-use ticket,
- the cookie properties,
- the provider,
- a link to Cloudflare’s privacy notices.
In my implementation, I do not additionally send remoteip to Siteverify.
This is a technical description of my implementation, not a legal assessment.
7. Operations and security boundaries
7.1 Turnstile is not an absolute bot block
Turnstile makes automated abuse considerably harder, but does not prevent every possible form of automation.
For example, the following remain conceivable:
- human CAPTCHA solvers,
- high-quality browser automation,
- compromised real browsers,
- abuse within permitted rate limits after successful validation.
7.2 The ticket is local
The njs Shared Dictionary exists only on the respective Nginx origin.
With multiple origin servers, you need, for example:
- sticky routing,
- or shared ticket storage.
7.3 An Nginx reload can discard tickets
An Nginx reload can invalidate tickets that were just issued.
Since they are valid for no more than two minutes anyway, I consider that acceptable.
In the worst case, a visitor has to complete Turnstile again.
7.4 Behaviour if Cloudflare fails
My configuration operates fail closed.
If Turnstile validation fails, the following still work:
- standard pages,
- Ghost Admin,
- Content API,
- RSS,
- sitemaps.
Temporarily unavailable:
- new Magic Link delivery.
For an endpoint that can trigger external emails, I consider this behaviour more sensible than an automatic unprotected fallback.
7.5 Secrets
The Turnstile secret should be rotated immediately if it accidentally appears in:
- logs,
- backups,
- chats,
- ticketing systems,
- shell arguments
.
The sitekey, on the other hand, is public.
8. Multiple Ghost instances behind the same Nginx
The configuration so far is deliberately designed for a single Ghost website.
With multiple instances, global components should not be defined again for every site.
Global only once
For example:
- njs module,
js_import,- Shared Dictionary,
- rate-limit zones.
Separate per site
For example:
- Turnstile secret,
- origin,
- hostname,
- sitekey,
- Ghost upstream,
- vHost locations.
One possible configuration could contain multiple sites:
{
"sites": {
"www.site-a.example": {
"secret": "SECRET_A",
"origin": "https://www.site-a.example",
"action": "ghost_magic_link",
"ticketTtlSeconds": 120
},
"www.site-b.example": {
"secret": "SECRET_B",
"origin": "https://www.site-b.example",
"action": "ghost_magic_link",
"ticketTtlSeconds": 120
}
}
}The njs code would then need to select the correct site entry based on the normalised request host.
For shared browser assets with multiple Ghost instances, I would also prefer a neutral path such as /usr/local/share/ghost-turnstile/.
9. Conclusion
The key idea is not simply to put a CAPTCHA in front of a Ghost form.
The actual security boundary must be server-side in front of the endpoint that actually triggers the email.
The browser first runs Turnstile. A successful validation is checked server-side against Cloudflare Siteverify and then translated into a short-lived single-use ticket.
Nginx accepts exactly one Magic Link request with this ticket and discards it afterwards.
This means the theme and Ghost Portal are important to the user experience, but are not the actual security boundary.
It lies in front of:
/members/api/send-magic-link
I did not need to patch or fork Ghost for this.
Nor does an additional custom application service run.
The solution consists of a clearly scoped protection layer comprising:
- Cloudflare Turnstile,
- Nginx,
- njs,
- a browser adapter,
- short-lived single-use tickets.
It can be tested independently, rolled back, and specifically checked for compatibility after Ghost updates.
That was more important to me than a custom solution built deeply into Ghost.
10. Official documentation
- Cloudflare Siteverify: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
- Cloudflare Client Rendering: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
- Cloudflare Widget Configuration: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/
- Cloudflare Test Keys: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
- Cloudflare Hostname Management: https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/
- Nginx
auth_request: https://nginx.org/en/docs/http/ngx_http_auth_request_module.html - njs HTTP Module: https://nginx.org/en/docs/http/ngx_http_js_module.html
- njs Reference: https://nginx.org/en/docs/njs/reference.html
- njs Security Advisories: https://nginx.org/en/docs/njs/security.html
- Ghost Members in Themes: https://docs.ghost.org/themes/members
- Ghost Admin API: https://docs.ghost.org/admin-api
Join the conversation
Become a member of InitInsights to leave a comment.