Nuxt is two runtimes in one project, and this module installs into both. The entry point is the defineNuxtModule default export in src/module.js, registered under the relintio config key: it adds a Nitro server middleware that scores every request before your routes run, and a client-only plugin that presents a challenge when your API asks for one. Each half takes a different credential. Nitro holds the licence key (UP_LIVE_…), the HMAC key that signs challenge passports and outbound requests. The browser holds a publishable key (pk_live_…), which is public by design and can do exactly one thing: ask for a verdict.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@relintio/nuxt'],
relintio: {
// UP_LIVE_… — Nitro only. Never serialised into the page.
licenseKey: process.env.RELINTIO_LICENSE_KEY,
// pk_live_… — serialised into the client bundle, which is the point.
publishableKey: process.env.RELINTIO_PUBLISHABLE_KEY,
},
});Do not put the licence key in publishableKey. runtimeConfig.public is serialised into the HTML of every page, so a licence key placed there is published to every visitor, and anyone reading the page source can mint themselves a passport through your WAF. The module refuses the build rather than let that ship — see Two keys.
npx nuxi module add @relintio/nuxtThat installs the package and adds it to modules. Node 18 or newer, and the module declares nuxt: >=3.0.0. @nuxt/kit is a direct dependency accepting ^3.10.0 || ^4.0.0; the two engines come with it — @relintio/agent@^0.11.5 on the server and @relintio/browser-core@^1.0.0 in the browser.
With no relintio block at all the defaults read RELINTIO_LICENSE_KEY, RELINTIO_PUBLISHABLE_KEY and RELINTIO_API_URL from the environment. Because those are read at build time through process.env in the module defaults, a key that only exists at runtime should be supplied through runtimeConfig instead.
You register nothing by hand. setup() registers each half only when its key is present:
| Registered | When | How |
|---|---|---|
| Nitro middleware | server and a licenseKey |
addServerHandler({ middleware: true }), no route |
| Client plugin | client and a publishableKey |
addPlugin({ mode: 'client' }) |
#relintio alias |
always | Points at src/runtime |
The server handler is registered with no route, so it runs as middleware on every request rather than as a handler for one. Enforcement that only covers the routes someone remembered to list is enforcement with a list of holes.
A missing key is a warning, not an error, and the corresponding half is not registered at all — server: true with no licence key logs that your routes are unprotected and continues the build. Set server: false or client: false to silence the warning when you mean it.
Every option lives under the relintio key in nuxt.config.
| Option | Default | Meaning |
|---|---|---|
licenseKey |
process.env.RELINTIO_LICENSE_KEY |
UP_LIVE_…. Server side only. Goes into the private half of runtimeConfig. |
publishableKey |
process.env.RELINTIO_PUBLISHABLE_KEY |
pk_live_…. Goes into runtimeConfig.public and reaches the browser. |
apiUrl |
https://api.relintio.com/v1 |
Used by both halves. RELINTIO_API_URL overrides it. |
server |
true |
Register the Nitro middleware. |
client |
true |
Register the browser plugin. |
exceptPaths |
['/_nuxt', '/__nuxt', '/_ipx', '/favicon.ico'] |
Paths the middleware skips. Read the edge case on matching before you trust this default. |
onlyPaths |
[] |
Protect only these. Empty means everything not excepted. |
trustProxy |
true |
Read x-forwarded-proto, -host and -for. |
verifyOnMount |
false |
Ask for a verdict on page load. Only useful for a Nuxt app with no server of yours. |
onlyPaths and exceptPaths are forwarded to the engine only when non-empty, and are matched against the URL the visitor sent.
runtimeConfig splits into a private half that stays in Nitro and a public half that is serialised into the HTML of every page. A module that took one key and fed both halves would publish the licence key on every site that installed it, so this module takes two, writes each to exactly one side, and refuses to start when they are swapped:
| Given | Result |
|---|---|
publishableKey starting UP_ |
setup() throws. The build fails. |
licenseKey starting pk_ |
setup() throws. The build fails. |
A browser key that is not pk_… at runtime |
isUsable() logs an error, nothing is wired up, no key is transmitted. |
The asymmetry is worth stating plainly. A publishable key in licenseKey cannot sign a passport, so the server agent would reject everything it signed — an outage you would notice in minutes. A licence key in publishableKey works perfectly and is readable in the page source of every page you serve. Only one of those failures announces itself, which is why both are build errors rather than warnings.
src/runtime/server/middleware.js builds one UltimateProtectorNodeAgent per process, not per request: the agent holds the ruleset, the reverse-DNS cache, the blocked-IP set and the per-IP token buckets, and rebuilding it per request would refetch the ruleset on every page view and hand every visitor a fresh rate-limit bucket, which is to say no rate limit at all.
Nitro is not Express, and the engine reads properties Express adds to a request and Node does not. src/runtime/express-shim.js is that translation and nothing else: originalUrl, hostname (port stripped, IPv6 literals kept whole), secure, ip, and the status().type().send() and redirect() response helpers, each added only when something upstream has not already provided it.
runGuard is the part that can deadlock, which is why it lives in its own module with its own tests. A blocked request never calls the continuation. The agent writes its block page and returns, so a handler that waits on next alone stays pending until Nitro's request timeout — the site appears to hang, and the cause reads as Relintio being slow rather than as Relintio never releasing. Instead next only sets a flag, and the settling of the agent's own promise is what ends the wait:
| Outcome | Nitro carries on? |
|---|---|
Agent called next and wrote nothing |
Yes |
Agent answered without calling next |
No, and the wait ends immediately |
Agent called next and ended the response |
No — a route response must not land on top of a block page |
| Agent rejected, or threw synchronously | Yes. Fail open |
The handler then returns nothing. h3 reads event.handled from the underlying writableEnded and headersSent, so a request the agent answered stops there.
src/runtime/plugin.client.js is client-only by filename, because wrapping window.fetch during SSR would wrap Nitro's own request plumbing for every visitor at once. It wraps fetch so that a 403 carrying an X-Relintio-Challenge header presents the challenge and retries the request exactly once — never in a loop, and a challenge that is not solved hands back the original 403 rather than a synthesised one. State is held in useState('relintio') rather than a module-level ref, so one visitor's challenge is not shown to another.
The overlay is yours. The plugin gives you the state, the frame attributes and the check; it injects nothing into your layout.
<!-- app.vue -->
<script setup lang="ts">
const { $relintio } = useNuxtApp();
const frame = ref<HTMLIFrameElement>();
function onMessage(event: MessageEvent) {
if ($relintio.isChallengeSuccess(event, frame.value?.contentWindow)) {
$relintio.resolveChallenge();
}
}
onMounted(() => window.addEventListener('message', onMessage));
onBeforeUnmount(() => window.removeEventListener('message', onMessage));
</script>
<template>
<iframe
v-if="$relintio.state.value.isChallenging"
ref="frame"
:src="$relintio.state.value.challengeUrl"
v-bind="$relintio.frameAttrs"
/>
<NuxtPage />
</template>isChallengeSuccess makes three checks and all of them are required: the origin, because any page can post to any window; the source frame, because the right origin in the wrong frame is still the wrong frame; and an exact string rather than a prefix or a parse, because nothing the visitor controls should decide whether they passed. A challenge URL whose protocol is not http: or https: is rejected before it can become an iframe src.
The default exceptPaths match exactly, not by prefix. The engine treats a pattern as a prefix only when it ends in *. '/_nuxt' therefore excepts the literal path /_nuxt and nothing beneath it, so /_nuxt/entry.abc123.js is scored like any other request — and at the 1.0 rate-limit multiplier, since the route table's 2.0 applies to /assets/. If you want the build output skipped, write ['/_nuxt*', '/__nuxt*', '/_ipx*', '/favicon.ico'].
trustProxy defaults to trusting, and that is the safer direction here. Nitro sits behind a proxy on every target Nuxt ships for, and Nitro's own helpers trust these headers. Trusting a spoofed x-forwarded-proto: https on a plaintext connection adds Secure to the passport cookie, the browser then declines to send it back, and the visitor is challenged again — an annoyance. Not trusting a real one strips Secure from a passport that authorises passage through the WAF, and it then travels in clear text. Set trustProxy: false only when Nitro is genuinely internet-facing with nothing in front of it.
x-forwarded-for is used before the ruleset exists. The shim sets req.ip from the first hop because the honeypot check runs before the ruleset has been fetched and cannot use the engine's CDN-aware IP detection. Everywhere else the engine derives the client IP itself from the synced Cloudflare ranges.
The client half does not protect server-rendered fetches. The interceptor is a window.fetch wrapper installed in the browser. A $fetch issued during SSR runs inside Nitro, where the server middleware is what applies — which is the correct split, but it means verifyOnMount and the challenge overlay have no effect on the first paint of a server-rendered page.
The agent is built once from the first request's config. agentFor caches the instance for the life of the process, so changing runtimeConfig.relintio at runtime does not rebuild it. The first request after boot also awaits the initial ruleset sync inside the request; after that a stale ruleset refreshes in the background.
An expired licence fails closed. When the control plane answers expired or outdated, the engine serves 503 for protected requests and persists that state to disk, and it does not re-check until the process restarts. Every other failure path — an unreachable control plane, a rules fetch that times out, a geo lookup that does not resolve, a fault inside the agent — releases the request, because a security agent that blocks a page over its own outage has turned our problem into yours.
Security reports go to support@relintio.com, not to a public issue.
Proprietary. See LICENSE — the Relintio Proprietary License, which permits use of this module solely to integrate and operate the Relintio service under a valid, active licence, and reserves everything else: no copying or redistribution, no modification or derivative works, no reverse engineering, and no removal of proprietary notices. The software is provided as is, without warranty.