provideRelintio() puts a publishable key in the application injector and relintioInterceptor sits in the HttpClient chain watching for one response: a 403 from your own API carrying X-Relintio-Challenge. On that response the request is held, the hosted challenge is presented, and the request is replayed once the visitor has passed. RelintioService exposes the state as an Angular signal, so a template renders the challenge without a subscription. The protocol is not in this package — the publishable-key refusal, the http/https check on a challenge URL, the three-part postMessage validation, the ten-second timeout floor and the fail-open behaviour all live in @relintio/browser-core and are shared with the React, Vue, Svelte and Expo SDKs. This package is the Angular-shaped surface over it, and it decides nothing about security.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRelintio, relintioInterceptor } from '@relintio/angular-agent';
export const appConfig: ApplicationConfig = {
providers: [
provideRelintio({ publishableKey: 'pk_live_...' }),
provideHttpClient(withInterceptors([relintioInterceptor])),
],
};// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);npm install @relintio/angular-agent| Peer | Range |
|---|---|
@angular/core |
>=16.0.0 |
@angular/common |
>=16.0.0 |
rxjs |
^7.0.0 |
Angular 16 is a real floor rather than a courtesy. RelintioService publishes its state through signal() and unregisters through DestroyRef, and neither exists before 16. @relintio/browser-core ^1.0.0 is the only runtime dependency and comes with the install.
The template in Presenting the challenge uses the @if control-flow block, which is Angular 17. On 16, *ngIf with NgIf imported into the standalone component does the same thing.
Both providers belong in the same ApplicationConfig, and provideHttpClient must be there at all — relintioInterceptor only ever runs inside an HttpClient chain.
provideRelintio() returns a Provider[] carrying exactly one thing: the RELINTIO_CONFIG token. It starts nothing. RelintioService is providedIn: 'root', so it is constructed on first injection, and in an app whose only consumer is the interceptor that is the first HttpClient request rather than bootstrap. Inject RelintioService in your root component if you want the agent watching behaviour from the moment the app loads.
Omitting provideRelintio() is not a silent no-op. RELINTIO_CONFIG is injected without { optional: true }, so the missing provider surfaces as a NullInjectorError the first time the service is constructed — inside a request, which is later than you would like but louder than nothing.
withInterceptors([a, b]) builds the chain outermost first: a sees the request before b, and the response — or the HttpErrorResponse — travels back the other way, reaching b before a. relintioInterceptor works entirely on the return leg, in a catchError, so this ordering is the whole of its correctness. Register it last, nearest the backend.
An interceptor registered after it receives the error first. If that interceptor swallows errors — catchError(() => of(fallback)), a retry helper that maps failures onto an empty result, a global handler that reports and returns — then the 403 never reaches relintioInterceptor, X-Relintio-Challenge is never read, and no challenge is ever presented. Nothing throws, nothing is logged, and the network tab shows a challenge header arriving at a page that does not react to it.
The retry is next(request) with the request exactly as this interceptor received it. Transformations applied by interceptors registered before it — authorization headers, a base URL, correlation ids — are already on the request and survive the replay. Interceptors registered after it run a second time, so an interceptor with side effects on that leg sees two passes for one call.
Everything passed to provideRelintio(), typed as RelintioConfig and re-exported from this package.
| Field | Type | Default | Meaning |
|---|---|---|---|
publishableKey |
string |
— | Required. Must begin pk_. Anything else is refused; see below. |
apiUrl |
string |
https://api.relintio.com/v1 |
Control-plane base. Trailing slashes are trimmed once, at construction. |
challengeTimeoutMs |
number |
120000 |
How long a challenge may stay open before its promise rejects. Floored at 10000; a lower value is raised, not honoured. |
verifyOnMount |
boolean |
false |
Ask for a verdict when the service is constructed. See below. |
fallbackUrl |
string |
— | Declared on RelintioConfig and read by nothing in the core. Setting it has no effect. |
verifyOnMount is off for a reason. The interceptor reacts to what your own origin decided, which is the right shape when you have a server: protection is enforced there and this SDK makes the enforcement survivable for a real person. Turn it on when the Angular app is the entire product — a static bundle on a CDN with no origin of yours running an agent.
This is a browser package. Whatever key it holds is in a JavaScript bundle that every visitor downloads, so it takes a publishable key (pk_live_…) and nothing else. A publishable key is public by design and carries one capability: it may ask Relintio for a verdict and read the answer. It cannot read your rules, write telemetry, or cause a challenge pass to be issued.
Your licence key must never appear here. It is the HMAC key for challenge passports and for request signing, and anyone holding it can mint themselves a pass through your WAF. Handed one, RelintioAgent.isUsable() matches the pk_ prefix, fails, writes a console.error naming the problem, and the service sets usable = false. From that point the agent transmits nothing at all: verify() returns null without a request, so the key never reaches the network. What it does not do is throw — see Edge cases for what an unusable agent does to an intercepted request.
The overlay is yours. The service gives you the state, the attributes the frame must carry, and the check that decides whether a message event is genuine.
import { Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { RelintioService } from '@relintio/angular-agent';
@Component({
selector: 'app-relintio-challenge',
standalone: true,
template: `
@if (relintio.state().isChallenging) {
<iframe
#frame
[src]="frameSrc()"
[attr.sandbox]="relintio.frameAttrs.sandbox"
[attr.referrerpolicy]="relintio.frameAttrs.referrerPolicy"
[attr.title]="relintio.frameAttrs.title"
></iframe>
}
`,
})
export class RelintioChallengeComponent {
readonly relintio = inject(RelintioService);
private readonly sanitizer = inject(DomSanitizer);
@ViewChild('frame') frame?: ElementRef<HTMLIFrameElement>;
frameSrc() {
const url = this.relintio.state().challengeUrl;
return url ? this.sanitizer.bypassSecurityTrustResourceUrl(url) : null;
}
@HostListener('window:message', ['$event'])
onMessage(event: MessageEvent) {
if (this.relintio.isChallengeSuccess(event, this.frame?.nativeElement.contentWindow)) {
this.relintio.resolveChallenge();
}
}
}isChallengeSuccess is three checks and all three are required: the event origin equals the challenge URL's origin, event.source is that iframe's own contentWindow, and event.data is the exact string relintio_challenge_success rather than a prefix or a parse. Origin alone would let any frame on the challenge origin pass a challenge on the visitor's behalf. The component makes no decision; it forwards the event and calls resolveChallenge() when told to.
frameAttrs is { sandbox: 'allow-forms allow-scripts allow-same-origin', referrerPolicy: 'no-referrer', title: 'Security check' }. It is data, not enforcement — a component that does not bind it gets an unsandboxed frame and nothing warns. allow-top-navigation is absent on purpose, so a challenge page cannot move the visitor off the site it is protecting. allow-scripts with allow-same-origin is only safe because the challenge is served from an origin other than your app's; if you point apiUrl at a self-hosted control plane on your own origin, that combination lets the frame drop its own sandbox.
Only when verifyOnMount is set, or when you call RelintioService.verify() yourself. One POST to <apiUrl>/agent/decision with an X-Agent-Version header, a five-second abort, and a body carrying the publishable key, the current hostname, path and referrer, a return_url, agent_kind: 'angular', any up_token in the query string, and the telemetry the shared collector gathered. The wire field for the key is still named license_key; it carries the publishable key, and the server derives everything that matters from the key itself rather than from agent_kind, which is reported and not trusted.
The answer is a verdict — action, reason, reason_code, risk_score, ip, and challenge_url when a challenge is called for — and it is stored on state().verdict. It is not policy. A verdict of challenge opens the overlay; block is informational, because a block a browser imposes is a block a browser can decline to impose.
The collector runs once per verdict request and gathers user agent, screen and display, timezone and languages, probed fonts, plugins, a canvas hash, the WebGL renderer, an audio hash raced against a 120 ms budget, network conditions, and behavioural counters. The behavioural family is counters only — dwell time and how many times the pointer moved, a key was pressed, the page scrolled, the screen was touched. It never records what was typed or where the pointer went. The watcher starts when the service is constructed, not when a request is made, because a watcher created at request time always reports a visitor who has done nothing, which is the signature of the automation the signal exists to catch.
Only HttpClient is covered. This binding never patches a global. fetch, XMLHttpRequest and Axios calls leave the app without passing through relintioInterceptor, so a challenge on any of them surfaces as a plain 403 to whatever made the call. The core has an interceptFetch that wraps the global; the Angular package does not call it, and does not expose it.
A refused key makes the interceptor retry instead of challenge. RelintioService.challenge() returns Promise.resolve() when usable is false. The interceptor treats that resolution as a passed challenge and replays the request once, so a build shipped with a licence key or a missing key answers a 403 with one silent retry and no overlay. The console.error is the only signal. The state signal is also frozen: when the agent is unusable the service never subscribes, so state() returns its initial value for the lifetime of the app.
The interceptor does not look at the request URL. Any 403 carrying an X-Relintio-Challenge header triggers a challenge, whatever origin it came from. A third-party API that happens to send that header will put your visitors in front of a challenge page.
An unsolved challenge surfaces the original 403. A timeout, a dismissal or a teardown rejects the pending promise, and the inner catchError rethrows the error the server actually sent rather than a synthesised one. Your existing error handling sees the response it would have seen without this package. A retry that is challenged again is returned as-is — one retry, never a loop.
Concurrent failures produce one challenge. Three requests failing at once join the same pending promise, so there is one overlay and one iframe rather than three racing to resolve the same visitor. The second challenge URL is discarded; the first one on screen is the one that counts.
Every failure path fails open. An unreachable control plane, a non-2xx answer, an abort at five seconds, an unparseable body: all of them return no verdict, and the page renders. A security agent that blanks a page because it could not reach its own control plane has turned our outage into the customer's, which is worse than what it was guarding against.
Server-side rendering does not blow up, and does not do anything useful either. The behaviour watcher is guarded on typeof document, so constructing the service on the server is safe. verifyOnMount is not guarded: on the server there is no location, so the decision call goes out with an empty domain, a path of / and a return_url of https://localhost/, and the verdict it gets back is about nothing. Leave verifyOnMount off under SSR, or gate it on the platform.
Teardown is tied to the injector. DestroyRef.onDestroy unsubscribes the listener and disposes the agent, which rejects any open challenge and clears the listener set. Because the service is root-provided, that happens when the application injector is destroyed rather than per route. A disposed agent refuses to arm a new challenge, so no timer outlives the app.
Security reports go to support@relintio.com, not to a public issue.
MIT. See LICENSE.