I suspect something like OpenClaw, using a real browser and real email account, with the goal to post comment spam later. The domains I’m getting sign ups from often have travel-related names.
One suggestion @bastien: for the future, you might want to separate transactional and newsletter sending as much as possible. I use a non-Mailgun SMTP provider for transactional mail. That way, even if transactional SMTP sending reputation is occasionally tarnished by a spam attacks, newsletter sending reputation isn’t destroyed. There is only one provider that you can simply use for newsletters (Mailgun), so it’s critical to maintain a good sending reputation, and not have your account suspended. There are multiple providers that work for transactional: Mailersend, PostMark, etc. I use Postmark, and have had a good experience with them overall (and they’ve been understanding when I get hit by a new spam attack that my mitigations don’t cover).
Just a thought.
@curiositry That’s a good idea, actually. For now, I’ve managed to completely stop the spam, but it’s true that with the arrival of OpenClaw and all that, if Ghost doesn’t find a radical solution, the problems will likely multiply. I imagine I’ll turn more to Plunk or Notifuse for transactional emails, since they’re more open than Postmark.
When youre setting up mailgun you should be using a “bulk mail” subdomain which should help with keeping things separate. that said, i did not do this… yet. i actually went the other way around, i moved my transactional emails over to mailgun from sending from my mail server to maintain my mail server’s IP good reputation.
on a different note - I had a shower thought, the introduction of the ghost-cli tool which makes claudecode interaction almost effortless could have the potential to increase automation of spam like this…
@bastien I’ve experimented with Plunk, and the developer was responsive. I will look into Notifuse.
I’ve decided to disable subscriptions for a while after noticing unlikely subscribers and now after checking the sent emails don’t have a good reason to risk email reputation for the few people who could be really interested.
This is interesting: this is a recent signup and seemed to clicked on EVERY link from the latest post’s newsletter. I don’t know if I’m just paranoid now haha
These are most probably traces of automated checks by systems used by commercial email services, to track phishing attacks for their employees. If you are able to check their user agents, maybe you can see some signs about that.
This is one of the reasons I’m highly against using “magic links” to get confirmations. Because these tools automatically checks all the links inside incoming emails, which practically means “approving” all “magic links” immediately. This also explains “immediate approvals” of some signup requests. I truly believe, actions like “signup” should demand real person act (like entering OTP), instead of clicking a link.
A few days ago, I noticed almost all of the spam requests coming to Synaps Media doesn’t have “integrityToken” at all. Then I realized that integrity tokens on signup requests are not validated by default in Ghost. I don’t why, but it’s not enabled in default config.
Please be sure you have this on your Ghost config:
"verifyRequestIntegrity": true
This will force calling integrityToken endpoint first for the attacker.
But I’m sure this was not the case previously. Probably attacker noticed that integrityToken is not checked anyway, so started calling send magic link endpoint without it. Another possibility is there are multiple attackers, using different styles.
And I’m pretty sure attackers also follow this topic. Because of that, I’m not sharing all of the measures I use in Synaps Media, especially the ones that don’t apply for self-hosters. I recommend being aware of this while sharing your observations and ideas.
I imagine because it would break all existing automations that use the send-magic-link endpoint (as far as I interpret it).
I think this is the most logical and plausible explanation for most scenarios, which makes me guessing the usefulness of that feature and/or the accuracy of the data.
It’s frustrating, and yes, our Mailgun account is also experiencing issues. The member count is high, and they are now confirming addresses. However, days later, these same email addresses are bouncing. Mailgun said this is affecting our sender profile. I don’t get this on any other platform. We’ve had TOR blocked. This all started again in the past week.
Is this just a self-hosting issue and does anyone get this using Ghost(Pro)
Did you enable verifyRequestIntegrity in your Ghost config?
Does it work like this in the docker-compose.yml?
services:
ghost:
image: ghost:6-alpine environment: spam__verifyRequestIntegrity: true
A top-level env variable would do the trick:
environment:
verifyRequestIntegrity: true
Thanks!
Would it make my other workaround obsolete? https://forum.ghost.org/t/possible-spam-signup-remedy/62574
I reckon it works in a similar fashion?
What I have learned while managing this issue for 1400 sites: don’t rely on a single measure.
Also: what works for others might not work for you.
This change is a great measure to make the requests more expensive − but it’s like saying “my front door is locked, nobody will steal the gold bars I put on my kitchen table”. Yeah…it’s harder than before, but if somebody wants to, they can ram the whole door. Messier, but does the trick.
Right on. I will leave both methods active. My 403 workaround has been protecting me quite well so far but why not add an extra lock to the door if it’s done by simply setting the environment variable.
This is hitting us as well, even today. So we quickly put this band-aid in place:**
Two-layer defence against spam signups (self-hosted + Traefik)**
We run Ghost self-hosted behind Traefik and implemented a two-layer approach that’s been working. I don’t yet know for how long and how effective.
Layer 1 — Traefik rate limit
Add a dedicated router with a ratelimit middleware targeting only the signup endpoint, same IP / shard bucket:
- "traefik.http.middlewares.signup-ratelimit.ratelimit.average=2"
- "traefik.http.middlewares.signup-ratelimit.ratelimit.period=1h"
- "traefik.http.middlewares.signup-ratelimit.ratelimit.burst=3"
- "traefik.http.routers.ghost-signup.rule=Host(`yourdomain.com`) && Path(`/members/api/send-magic-link/`) && Method(`POST`)"
- "traefik.http.routers.ghost-signup.priority=100"
- "traefik.http.routers.ghost-signup.middlewares=signup-ratelimit"
- "traefik.http.routers.ghost-signup.service=ghost"
Tested: first 3 attempts return 201, 4th and 5th get 429 at 0ms (Traefik absorbs them before Ghost sees them).
Layer 2 — Webhook auto-delete nameless members
Key insight first: the “Display name in signup form” toggle in Portal is client-side validation only. Bots bypass Portal entirely and POST directly to /members/api/send-magic-link/ — no name required. Any member created without a name definitionally bypassed Portal.
Add a member.added webhook handler, and check member.name before any other processing. If empty, call DELETE /ghost/api/admin/members/{id}/ immediately using a Ghost Admin API JWT. Log it and return early.
This catches bots that rotate IPs and stay under the rate limit but still skip the name field.
Conclusion
The current practice of ignoring the name field is bot-stupid so wont last long I suppose. For now, depending on the quality of their stolen credentials, it will make the phishing harvesting, which seems to be the purpose of these bots, a bit harder. After all, their success will be less if they use a fake name that does not match the email receipient’s.
This is an excellent solution for my case, I’m using Ghost on Kubernetes with Traefik v3 deployed with Helm. Using qwen v3 I’ve adapted and tested your approach successfully.
Prompt:
generate a kubernetes manifest equivalent for this traefik v3 middleware:
- "traefik.http.middlewares.signup-ratelimit.ratelimit.average=2"
(...the rest of your config)
Response (tested and working):
To translate Traefik v3 static configuration (labels/flags) into Kubernetes manifests, we need to leverage the Traefik CRDs (Custom Resource Definitions). In a K8s environment, we decouple the middleware definition from the router definition, then reference them via an IngressRoute (or standard Ingress with annotations, though CRDs are preferred for v3 features like precise priority and middleware chaining).
Here is the breakdown of the logic:
- Middleware: Define a
MiddlewareCRD with the rate-limiting spec. - Service: Ensure the
ghostservice exists (I will provide a placeholder). - Router: Define an
IngressRoutethat matches the Host, Path, and Method, applies the middleware, and sets the priority.
1. The Middleware CRD (ratelimit-middleware.yaml)
This defines the signup-ratelimit middleware. Note that Traefik’s rate limit uses a token bucket algorithm.
average: 2 requests per period.period: 1 hour.burst: 3 requests allowed above the average.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: signup-ratelimit
namespace: default # Adjust to your target namespace
spec:
rateLimit:
average: 2
period: "1h"
burst: 3
2. The IngressRoute CRD (ghost-ingressroute.yaml)
This replaces the router configuration. It explicitly handles the Host, Path, and Method matching, and assigns the priority.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ghost-signup
namespace: default # Adjust to your target namespace
spec:
entryPoints:
- websecure # Assuming HTTPS; use 'web' for HTTP if needed
routes:
- match: Host(`yourdomain.com`) && Path(`/members/api/send-magic-link/`) && Method(`POST`)
kind: Rule
priority: 100
middlewares:
- name: signup-ratelimit
namespace: default # Must match the namespace of the Middleware CRD
services:
- name: ghost
port: 80 # Adjust to the actual port exposed by your Ghost service
3. The Service Placeholder (ghost-service.yaml)
For completeness, here is how the ghost service might look. Ensure this matches your actual deployment.
apiVersion: v1
kind: Service
metadata:
name: ghost
namespace: default
spec:
selector:
app: ghost # Ensure this matches your Deployment labels
ports:
- protocol: TCP
port: 80
targetPort: 2368 # Default Ghost port
Key Considerations & SRE Notes
1. CRD Installation
Ensure you have the Traefik CRDs installed in your cluster. If you installed Traefik via Helm, this is usually default. If not, apply them from the official repo:
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.0/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
2. Namespace Scope
Traefik Middlewares are namespaced resources.
- If your
IngressRouteandMiddlewareare in the same namespace, you can omit thenamespacefield in themiddlewaresreference. - If they are in different namespaces, you must specify the
namespacein theIngressRoutemiddleware reference, and ensure Traefik is configured to watch cross-namespace resources (viaproviders.kubernetesCRD.allowCrossNamespacein Traefik’s static config).
3. Rate Limiting Logic Check
- Current Config: 2 requests/hour with a burst of 3.
- Implication: This is extremely restrictive. It allows a burst of 3 requests immediately, then throttles to ~1 request every 30 minutes.
- SRE Recommendation: For a “send magic link” endpoint, this might be too aggressive for legitimate users who might click “resend” quickly. Consider if
average=10andperiod=1m(10 requests per minute) might offer better UX while still preventing abuse. Monitor thetraefik_middleware_reject_totalmetric to tune this.
4. Alternative: Standard Kubernetes Ingress
If you prefer not to use IngressRoute CRDs, you can use standard Ingress with annotations, but priority and method matching are harder to express cleanly in v3 without CRDs. The annotation approach would look like this (less recommended for complex rules):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ghost-signup
annotations:
traefik.ingress.kubernetes.io/router.middlewares: default-signup-ratelimit@kubernetescrd
traefik.ingress.kubernetes.io/router.priority: "100"
# Note: Method matching and complex path rules are limited in standard Ingress
spec:
rules:
- host: yourdomain.com
http:
paths:
- path: /members/api/send-magic-link/
pathType: Exact
backend:
service:
name: ghost
port:
number: 80
Warning: Standard Ingress does not natively support Method(POST) filtering in Traefik without additional middleware or regex hacks on the path. The IngressRoute CRD is the superior choice here.
Verification Steps
- Apply the Middleware:
kubectl apply -f ratelimit-middleware.yaml - Apply the IngressRoute:
kubectl apply -f ghost-ingressroute.yaml - Check Traefik Dashboard or logs to confirm the route is loaded.
- Test with
curl:# Should succeed (within burst) curl -X POST https://yourdomain.com/members/api/send-magic-link/ # Should fail with 429 Too Many Requests after exceeding burst/rate
Evidence:
-
curl testing output:
-
My ghost logs:



