PingAM + FingerprintJS: Modern Device Profiling
Trust in a login usually stops at the credential: a password proves something you know, MFA proves something you have. Device trust adds a dimension neither of them covers — whether the device presenting those credentials has a history with you. A browser that has signed in successfully a hundred times is not the same risk as one you’ve never seen, even when both type the right password. Zero-trust thinking makes this explicit: identity isn’t established once at the perimeter, it’s continuously assessed, and the device is one of the strongest signals you have.
Device fingerprinting is how you get that signal on the web, where you don’t control the endpoint. Recognize the returning browser and you can calibrate friction to risk instead of taxing everyone equally: wave through the laptop that logs in from home every morning, ask a brand-new device for a one-time code, and block outright the session that appears in Vancouver and then in Dubai ninety seconds later.
PingAM does ship device profiling out of the box, but it shows its age: a fixed script collecting a fixed list of browser attributes, essentially unchanged for years. FingerprintJS is a chance to extend the platform with something actively maintained instead — a library that probes far more signals, keeps pace with browser changes, and computes a stable identifier rather than leaving you a bag of raw attributes to compare. Here’s how to wire it into a PingAM journey without painting yourself into a corner. I’ve built it both ways; this is the version I’d keep.
Decide where the JavaScript runs
FingerprintJS runs in the browser, so something has to deliver it. You have two options.
Let AM inject it. A ScriptTextOutputCallback ships the fingerprinting code down inside the login response, the browser runs it, posts the result back, and submits. Everything is server-controlled and your login page doesn’t need to know FingerprintJS exists. This is the right call if you’re on the stock XUI and not building your own UI.
Let your app own it. If you’re already building a custom login UI on the platform’s JavaScript SDK, FingerprintJS is just a dependency — npm install and you’re done. No injected scripts, no bundle re-sent on every attempt. It’s cached like any other asset, and you can start computing the fingerprint the moment the page loads instead of waiting for the journey to ask. The whole integration is one small helper:
import FingerprintJS from '@fingerprintjs/fingerprintjs';
let fpPromise;
// Returns { visitorId, components }. Loads the agent once and reuses it.
export async function collectFingerprint() {
fpPromise = fpPromise || FingerprintJS.load();
const fp = await fpPromise;
const r = await fp.get();
return { visitorId: r.visitorId, components: r.components };
}
If you have a custom UI, use the second option. It’s simpler, faster, and keeps AM config clean. Best of all, the app can start fingerprinting before it ever talks to PingAM — kick off FingerprintJS as the login page loads, and by the time the user has typed their credentials, the fingerprint is already computed and waiting. The journey never has to pause for it.
The built-in device print vs FingerprintJS
Before you reach for FingerprintJS, it’s worth knowing what PingAM already ships. The out-of-box Device ID (Match) authentication module runs a client-side devicePrint script — the one you may have seen pasted around — that collects a set of browser attributes and posts them back. Reading the script itself, it gathers:
devicePrint.screen = collectScreenInfo(); // screen.width, screen.height, screen.pixelDepth
devicePrint.timezone = collectTimezoneInfo(); // getTimezoneOffset(), in minutes
devicePrint.plugins = collectBrowserPluginsInfo(); // navigator.plugins filenames
devicePrint.fonts = collectBrowserFontsInfo(); // a fixed 32-entry font list, detected by text width
// + navigator.userAgent, platform, appName/appVersion, language, and related navigator fields
output.value = JSON.stringify(devicePrint);
collectGeolocationInfo(function (geo) { devicePrint.geolocation = geo; submit(); });
So the module emits a raw attribute bag as JSON and auto-submits — it does not compute any identifier or hash in the browser. Recognition happens on the server: per the PingAM documentation, the Device ID module stores device profiles in a user profile attribute (the module’s Profile Storage Attribute, deviceIdAttrName), and those stored profiles can be encrypted via the Device ID Service (am.services.deviceid.encryption). In other words, “is this the same device?” is decided by comparing the freshly-collected attributes against profiles saved on the user entry.
FingerprintJS works differently, and the project’s own reference notes the gap plainly: it is “far more comprehensive than the OOB Device Profile node.” Rather than a fixed attribute list, it actively probes roughly 41 signals — canvas, WebGL, audio, fonts, media queries, hardware — and computes a visitorId hash from device entropy, so it can re-recognise a returning browser even after browser storage is cleared. The version wired into this project is FingerprintJS 5.2.0, MIT-licensed.
PingAM devicePrint (Device ID module) |
FingerprintJS 5.2.0 | |
|---|---|---|
| Browser output | Raw attribute bag (JSON), auto-submitted; no identifier computed client-side | A visitorId hash computed from device entropy, plus components |
| Signals | screen, timezone offset, navigator.plugins, a fixed 32-entry font list, navigator.* (per the script) |
~41 signals incl. canvas, WebGL, audio, fonts, media queries, hardware (per the project reference) |
| How “same device?” is decided | Server-side, comparing against device profiles stored in a user attribute (Profile Storage Attribute); storage can be encrypted (PingAM docs) | Compare the visitorId; re-recognises even after storage is cleared |
| Dependency / licence | Bundled with PingAM, supported, no external dependency | External library; the installed build is v5.2.0 under the MIT licence |
| Known limits | — | The OSS visitorId drifts over weeks and is spoofable — a risk signal, not a hard identity anchor; OSS gives no bot score, VPN/proxy/Tor, or IP-geo (those need Fingerprint Pro) |
The practical read: the built-in module gives you a coarse, dependency-free “have this browser’s attributes changed?” check, with matching and storage handled inside PingAM. FingerprintJS gives you a much richer, entropy-derived identifier that survives a storage wipe and is better suited to real device recognition for risk scoring — at the cost of an external dependency, and with the standing caveat (from the project reference) that the OSS visitorId should raise or lower risk rather than serve as proof of identity.
The contract is one hidden field
The handshake between the app and the journey is smaller than people expect. The server sends an empty, invisible field with a known name — fpResult — that means “put the fingerprint here.” The app watches each step for that field:
export function findFpCallback(step) {
return (step?.callbacks || []).find(
(c) => c.getType?.() === 'HiddenValueCallback' && c.getOutputByName?.('id', '') === 'fpResult',
);
}
export function isFingerprintStep(step) {
return !!findFpCallback(step);
}
When it appears, the app runs FingerprintJS, writes { visitorId, components, location } into the field, and continues — and if fingerprinting failed, it still answers, with an error payload the journey can branch on rather than an empty field it can’t distinguish from a broken contract:
const cb = findFpCallback(step);
const payload = fp && fp.visitorId
? { visitorId: fp.visitorId, components: fp.components, location: location ?? null }
: { error: (fp && fp.error) || 'fingerprint-failed' };
cb.setInputValue(JSON.stringify(payload));
onSubmit();
That’s the whole protocol. There’s no special callback type — just a naming convention. Which means: treat fpResult as a real interface. Write it down. If you rename it on one side and forget the other, nothing errors; the app just submits an empty field and the journey silently does the wrong thing.
The three pieces
Once it’s running, the system is three parts:
- The login UI — your SDK-based app. It renders the journey step by step and, when it sees
fpResult, runs FingerprintJS and fills it in. - A collect node — a Scripted Decision node in PingAM that sends the empty
fpResult, then reads the browser’s answer back and stashesvisitorId+ signals in shared state. - A risk node — takes that fingerprint, checks whether it’s seen this
visitorIdbefore, scores it against your rules (new device, new location, impossible travel), and bands the score: low → allow, medium → step up to a one-time code, high → deny.
The data flow, start to finish: browser computes the fingerprint → app posts it in fpResult → collect node reads and stores it → risk node recognises, scores, and decides.
Doing it right
A few things that separate a demo from something you’d actually run:
- Prefetch the fingerprint. Start
FingerprintJS.load()when the login page loads, not when thefpResultstep arrives. FingerprintJS’s main cost is computing the signals; do it while the user types their password and the step submits instantly. BecausecollectFingerprint()memoizes its promise, prefetching is just calling it once as the page mounts — thefpResultstep later awaits the same promise and gets the already-computed result. This is the single biggest perceived-performance win, and it’s only available in the app-owned approach. - Keep collect and decide separate. One node that reads the device and another that judges it is easier to reason about, test, and re-tune.
- Trim the payload. FingerprintJS’s full
componentsobject is several KB. If the risk node only needsvisitorId(and maybe a hash), don’t ship the whole thing into AM on every login. - Remember it’s a hunch, not a passport. The open-source FingerprintJS
visitorIdis probabilistic — it drifts when browsers update. Use it to raise or lower risk, never as the sole proof of identity.
The short version
Let the app own FingerprintJS, agree on fpResult as the contract, and split the work into a collect node and a risk node. Prefetch so it feels instant, and treat the device as a strong signal rather than an identity. Get those right and you’ve added real intelligence to a login for surprisingly little code.
If you have any questions, feel free to ping me.