Relock Integration guide
Relock adds a non-reproducible, device-bound layer to web sessions on the application-side. No identity-provider, browser, or end-user rollout.
This guide describes:
- The integration model
- The responsibilities of the one layer you actually touch
- The decisions every deployment resolves
- A fully worked reference deployment (AWS Application Load Balancer + OpenResty/NGINX) with the exact scripts and configuration
The identity-provider wiring is the only part that differs between deployments. This guide covers everything that is common; the provider-specific steps live in three short companion articles:
Throughout this documentation, replace
app.example.comwith the domain your application is served from — the domain the Relock relay sits in front of.
1. Reference architecture
Relock is composed of three parts with a strict division of labor.
Browser JavaScript (relock.js). Executes in the browser, transparent to the end user, and requires no integration. It generates and holds the device-resident key material — an OriginKey in IndexedDB and a LocalKey in a cookie, both non-extractable, plus a scrypt-derived Tesseract token — and attaches proof-of-possession headers to outbound requests.
Relay. The logic layer between browser and server. It injects relock.js into responses, derives a stable session anchor, forwards the security headers to the server, and enforces the server's verdict (including session termination). In the reference deployment this is the OpenResty/NGINX layer — two Lua scripts plus an auth_request subrequest. The same responsibilities can be met by a library embedded directly in the application when that placement is preferable.
Server (Relock Gatekeeper). Performs the cryptographic verification and returns a signal: pass, terminate, or accredit. It is reached by the relay over /relock/* and the internal /nginx/authn subrequest.
The load-bearing invariant: the customer never modifies
relock.jsor the server. Both are Relock-operated. All integration effort lives in the relay layer — which is what makes Relock adoptable application-side.
2. Integration approach
Every relay must satisfy four responsibilities. Two are stated precisely, because the precision is where the security properties live.
1. Inject the JS into the browser. The relay inserts <script src="/relock/relock.js"> into every HTML response, before </head>, idempotently, carrying the application's CSP nonce if one is present. Non-HTML responses and Relock-owned routes are skipped.
2. Derive a stable session anchor — not "read the token." The relay does not forward the raw IdP token. It observes the session artifact and normalizes it to a stable, non-secret, per-session anchor — X-Key-Accesstoken = MD5(subject + session anchor) — and forwards only that. The anchor must be stable across token refresh, unique per session, and must not expose token material to the Relock Server. The correct mental model is observe the session and reduce it to a stable anchor, not read and relay the credential.
3. Pass the headers to the server. Two distinct header families:
- Proof headers, attached by the browser JS:
X-Key-Origin(ECDSA, OriginKey/IndexedDB),X-Key-Signature(ECDSA, LocalKey/cookie),X-Key-Token(scrypt one-time, TesseractKey). - Anchor headers, minted by the relay itself:
X-Key-Accesstoken,X-Key-Subject, plusOriginfor origin binding.
The relay both mints the anchor headers and forwards both families to the Gatekeeper via the internal subrequest.
4. Enforce the verdict, including kill + logout. The relay acts on the Gatekeeper's response: 200 passes through (verification may be asynchronous); 401 (no valid proof) routes to /relock/terminate; 403 (unrecognized/new device) routes to /relock/accreditation. Termination is not a no-op drop of Relock's own state — see Q2.
3. Verification model
The per-request path in the reference deployment:
browser attaches proof headers
→ ALB validates its OIDC session and injects x-amzn-oidc-*
→ oidc_hash.lua derives the anchor
→ auth_request /nginx/authn hands proof + anchor to the Gatekeeper
→ verdict enforced
Verification is tiered by method:
GET→ LocalKey signature only (lightweight; read operations).POST→ additionally verifies thatX-Key-Tokenwas derived from the correct Tesseract rotation state (full attestation; state-changing operations).
Routes that bypass verification: /relock/* (Gatekeeper routes, auth_request off) and static assets (.svg, .png, .ico, .css, .js).
4. Key architecture questions
Every new integration answers the same three questions. They are placement-independent — the same three apply whether the relay is a proxy or a library.
Q1 — How is the session handled?
The relay needs something stable per session to key the binding to. The question is: what is the session artifact, and can the relay derive a stable anchor from it?
In the reference deployment the anchor is derived from the ALB-injected x-amzn-oidc-accesstoken JWT. The relay auto-detects several token shapes — Auth0 (namespaced email + auth_time claims), Okta (sub + auth_time), Entra (unique_name + sid), Ping (username/preferred_username + auth_time/sid) — and falls back to cookie-only verification when none match. The anchor source can therefore be a provider JWT claim or, in the fallback, the session cookie itself. The design constraint is stability, not format: the anchor must not change mid-session and must not leak secret material.
Q2 — How is logout handled?
This is the sharpest of the three. Terminating Relock's own binding is necessary but not sufficient: if the layer that issued the session is still live, the next request silently re-authenticates and the session respawns.
/relock/terminate drops Relock's binding and the app/session cookies — but the ALB OIDC session cookie and the IdP SSO session can each re-mint access on their own. Termination must therefore cascade to the layer capable of respawning the session (the IdP / SSO session), not merely the layer that presents it. Every integration must answer: which layer can re-mint this session, and can the relay invalidate it from where it sits? IdP logout wiring is part of the integration, not an afterthought — it is why the IdP logout URLs, configured in each companion article, point back to the application domain.
Q3 — What level of abstraction is appropriate for relay placement?
Two placements, each with a clear trade-off:
- In-application (per-app). Direct access to session state and lifecycle; the strongest answer to Q1/Q2 for that app. Cost: per-application integration effort.
- Reverse proxy in front of a cluster (many services / subdomains). One integration point covering many services. The reference NGINX config already leans this way — a wildcard
server_name *.${HOST}plus a subdomain-rewriting map fronts many subdomains from a single relay. Cost: the relay may not own the session it must read and kill, and it centralizes anchor/termination logic on a shared layer.
Decision heuristic: the placement is appropriate when the relay sits on a layer that can both (a) observe a stable session anchor (Q1) and (b) invalidate the session-issuing layer on terminate (Q2). Proxy-in-front maximizes coverage; in-app maximizes control. Choose per the session topology, not per convenience.
5. Reference deployment
The reference deployment is a concrete instantiation of the model above: an identity provider of your choice, an AWS Application Load Balancer terminating OIDC at the edge, and OpenResty/NGINX carrying the Relock relay in front of the application.
5.1 Information flow
┌──────────────────────────────────────┐
│ User's browser │
│ relock.js holds device keys │
│ attaches proof headers │
└──────────────────────────────────────┘
│ HTTPS
│
OIDC ▼
┌────────────────┐ ┌──────────────────────────────────────┐
│ │ │ AWS Application Load Balancer │
│ IdP │◀──────▶│ terminates OIDC with the IdP │
│ │ │ injects x-amzn-oidc-* │
└────────────────┘ └──────────────────────────────────────┘
│
│
▼
┌──────────────────────────────────────┐
┌────────────────┐ │ NGINX / OpenResty (relay) │
│ Relock server │ │ relock.lua inject JS │
│ (Gatekeeper) │◀──────▶│ oidc_hash.lua derive anchor │
│ │ │ auth_request verify verdict │
└────────────────┘ │ (/relock/*, /nginx/authn) │
│ │
└──────────────────────────────────────┘
│
│
▼
┌──────────────────────────────────────┐
│ Application backend :8080 │
│ │
└──────────────────────────────────────┘
Components:
- User's browser. Runs
relock.js, which creates and rotates the non-extractable device keys and attaches proof-of-possession headers (X-Key-Origin,X-Key-Signature,X-Key-Token) to every request. - Identity provider. Authenticates the user and issues the OIDC tokens. The ALB drives the OIDC exchange; where the provider does not expose a stable claim by default, a small provider-side action stamps the claims the relay needs (Auth0 only — see its companion article).
- AWS Application Load Balancer. Terminates OIDC with the IdP at the edge, maintains its own session cookie, and injects the identity headers (
x-amzn-oidc-accesstoken,x-amzn-oidc-data,x-amzn-oidc-identity) into requests forwarded to the target. - NGINX / OpenResty (Relock relay). Injects
relock.jsinto HTML responses, derives the session anchor from the ALB-injected token, forwards proof + anchor headers to the Gatekeeper via an internalauth_request, and enforces the returned verdict. - Relock server (Gatekeeper). Verifies each proof against the enrolled device and returns pass / terminate / accredit. Reached only over
/relock/*and the internal/nginx/authnsubrequest; it never sees raw IdP tokens.
Two phases. On the first visit (session birth) the ALB completes the login, the relay injects relock.js, the browser enrolls the device, and first contact with the Gatekeeper returns 403 → /relock/accreditation. In steady state (the life of the session) the browser attaches proof headers on every request, the relay derives the anchor and forwards both header families, and the Gatekeeper returns 200 (pass), 401 (terminate), or 403 (accredit).
5.2 The relay
The relay is identity-provider-agnostic: the same two Lua scripts and NGINX configuration serve every provider. Only the IdP console settings and the ALB authenticate-oidc values change between deployments — those are covered in the companion articles.
File layout
/etc/nginx/lua/oidc_hash.lua # anchor derivation (inbound)
/etc/nginx/lua/relock.lua # relock.js injection (outbound)
/etc/nginx/conf.d/default.conf # rendered from template.https.conf
Environment variables substituted into the config
| Variable | Meaning |
|---|---|
${HOST} | your application domain (e.g. app.example.com) |
${UPSTREAM} | Relock Gatekeeper upstream host |
${RESOLVER_IPS} | DNS resolver IPs for dynamic upstream resolution |
${CRT} / ${KEY} | TLS certificate and key paths |
oidc_hash.lua — derive the session anchor (Q1)
Reads the ALB-injected access token, detects the provider from the JWT payload, and emits X-Key-Accesstoken = MD5(subject + anchor) and X-Key-Subject. It never forwards the raw token; if no provider profile matches, it sets no headers and Relock falls back to cookie-only verification.
local token = ngx.req.get_headers()["x-amzn-oidc-accesstoken"]
if token then
local payload_b64 = token:match("^[^.]+%.([^.]+)%.")
if payload_b64 then
local p = payload_b64:gsub("%-", "+"):gsub("_", "/")
local pad = #p % 4
if pad == 2 then p = p .. "==" elseif pad == 3 then p = p .. "=" end
local json = ngx.decode_base64(p) or ""
local email = json:match('"sub"%s*:%s*"([^"]*)"')
local auth_time = json:match('"auth_time"%s*:%s*(%d+)')
local unique_name = json:match('"unique_name"%s*:%s*"([^"]*)"')
local sid = json:match('"sid"%s*:%s*"([^"]*)"')
-- Ping candidates
local ping_user = json:match('"username"%s*:%s*"([^"]*)"')
or json:match('"preferred_username"%s*:%s*"([^"]*)"')
-- Auth0 namespaced claims (stamped by the Post-Login Action)
local auth0_email = json:match('"https?://[^"]+/email"%s*:%s*"([^"]*)"')
local auth0_auth_time = json:match('"https?://[^"]+/auth_time"%s*:%s*(%d+)')
if auth0_email and auth0_auth_time then -- AUTH0
ngx.req.set_header("X-Key-Accesstoken", ngx.md5(auth0_email .. auth0_auth_time))
ngx.req.set_header("X-Key-Subject", auth0_email)
elseif email and auth_time then -- OKTA
ngx.req.set_header("X-Key-Accesstoken", ngx.md5(email .. auth_time))
ngx.req.set_header("X-Key-Subject", email)
elseif unique_name and sid then -- ENTRA
ngx.req.set_header("X-Key-Accesstoken", ngx.md5(unique_name .. sid))
ngx.req.set_header("X-Key-Subject", unique_name)
else -- PING
local subject = ping_user or email
local anchor = auth_time or sid
if subject and anchor then
ngx.req.set_header("X-Key-Accesstoken", ngx.md5(subject .. anchor))
ngx.req.set_header("X-Key-Subject", subject)
end
end
end
end
relock.lua — inject relock.js (Responsibility 1)
Runs as a body filter, buffers the HTML response, and injects the script tag before </head> (falling back to </body>, then append), idempotently, reusing any existing CSP nonce.
local uri = ngx.var.uri or ""
-- Do not inject on Relock-owned routes
if uri == "/relock" or uri:match("^/relock/") then
return
end
local ct = ngx.header["Content-Type"]
if not ct or not ct:find("text/html", 1, true) then
return
end
local chunk = ngx.arg[1]
local eof = ngx.arg[2]
if not ngx.ctx.relock_buffer then
ngx.ctx.relock_buffer = {}
end
if chunk and chunk ~= "" then
table.insert(ngx.ctx.relock_buffer, chunk)
ngx.arg[1] = nil
end
if eof then
local whole = table.concat(ngx.ctx.relock_buffer)
ngx.ctx.relock_buffer = nil
-- Avoid double injection
if whole:find('/relock/relock%.js', 1, false) then
ngx.arg[1] = whole
return
end
local nonce = whole:match('nonce="([^"]+)"')
local inject
if nonce then
inject = '\t<script src="/relock/relock.js" nonce="' .. nonce .. '" fetchpriority="high"></script>'
else
inject = '\t<script src="/relock/relock.js" fetchpriority="high"></script>'
end
local replaced = 0
whole, replaced = whole:gsub("</[Hh][Ee][Aa][Dd]>", inject .. "</head>", 1)
if replaced == 0 then
whole, replaced = whole:gsub("</[Bb][Oo][Dd][Yy]>", inject .. "</body>", 1)
end
if replaced == 0 then
whole = whole .. inject
end
ngx.arg[1] = whole
end
template.https.conf — the relay configuration
Wires the two scripts together: the /nginx/authn internal subrequest forwards proof + anchor headers to the Gatekeeper; /relock/ proxies Gatekeeper routes and bypasses verification; the default location / runs the anchor derivation, the auth_request verification, verdict enforcement (401 → /relock/terminate, 403 → /relock/accreditation), and the injection body filter.
resolver ${RESOLVER_IPS} ipv6=off valid=30s;
resolver_timeout 5s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
large_client_header_buffers 4 16k;
map $host $relock_upstream_host {
default "";
${HOST} ${UPSTREAM};
~^(?<sub>[^.]+)\.${HOST}$ ${sub}.${UPSTREAM};
}
upstream app_backend {
server 127.0.0.1:8080;
keepalive 32;
}
# HTTP -> HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name ${HOST} *.${HOST};
return 301 https://$host$request_uri;
}
# HTTPS reverse proxy
server {
listen 443 ssl fastopen=256;
listen [::]:443 ssl fastopen=256;
http2 on;
server_name ${HOST} *.${HOST};
root /relock;
ssl_certificate /relock/${CRT};
ssl_certificate_key /relock/${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Security headers (apply to all responses)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Reasonable proxy timeouts
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
send_timeout 60s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
large_client_header_buffers 8 32k;
location = /nginx/authn {
internal;
rewrite_by_lua_file /etc/nginx/lua/oidc_hash.lua;
proxy_pass https://$relock_upstream_host;
proxy_pass_request_body off;
proxy_ssl_server_name on;
proxy_ssl_name ${HOST};
proxy_set_header Content-Length "";
proxy_set_header Host $relock_upstream_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-AuthN-URI $request_uri;
proxy_set_header X-AuthN-Method $request_method;
proxy_set_header Origin $http_origin;
proxy_set_header Cookie $http_cookie;
proxy_redirect off;
}
# --- /relock prefix handling ---
# Redirect /relock (no trailing slash) to /relock/
location = /relock {
return 301 /relock/;
}
# Proxy ONLY paths that start with /relock/
location ^~ /relock/ {
auth_request off;
if ($relock_upstream_host = "") { return 444; }
proxy_ssl_server_name on;
proxy_ssl_name $relock_upstream_host;
rewrite_by_lua_file /etc/nginx/lua/oidc_hash.lua;
proxy_pass https://$relock_upstream_host;
proxy_set_header Host $relock_upstream_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header Cookie $http_cookie;
add_header X-Frame-Options SAMEORIGIN always;
proxy_redirect off;
}
# Fast path for real static URLs
location ~* \.(svg|png|ico|css|js)$ {
proxy_pass http://127.0.0.1:8080;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
location @error401 {
return 302 /relock/terminate;
}
location @error403 {
return 302 /relock/accreditation;
}
# --- Default app (WSGI) ---
location / {
rewrite_by_lua_file /etc/nginx/lua/oidc_hash.lua;
auth_request /nginx/authn;
auth_request_set $auth_status $upstream_status;
proxy_pass http://app_backend;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Cookie $http_cookie;
proxy_intercept_errors on;
error_page 401 = @error401;
error_page 403 = @error403;
proxy_max_temp_file_size 0;
# Inject relock.js via lua body filter
body_filter_by_lua_file /etc/nginx/lua/relock.lua;
header_filter_by_lua_block {
ngx.header.content_length = nil
}
}
}
A plain-HTTP variant (template.http.conf) is available for local/non-TLS environments; it is identical except for the TLS listener and redirect.
6. Identity-provider setup
The relay above is provider-agnostic. What differs per provider is (a) the redirect and logout URLs registered in the IdP console, (b) whether a small action is needed to expose a stable claim, and (c) the endpoint values entered into the ALB authenticate-oidc action. Follow the article for your provider:
| Provider | Anchor claims | Provider-side action | Supporting Docs |
|---|---|---|---|
| Okta | sub + auth_time | none needed | Okta setup → |
| Microsoft Entra ID | unique_name + sid | none needed | Entra ID setup → |
| Auth0 | namespaced email + auth_time | Post-Login Action | Auth0 setup → |
| Ping (PingOne) | sub + sid | none needed | Ping setup → |
7. Relock server configuration
Deploying the relay wires the request path, but Relock enforces nothing until the origin is registered on the Relock server. After the relay is live, sign in to your Relock server admin portal and add the application's origin (for example, https://app.example.com) to the list of protected origins. Adding the origin is what activates Relock session protection for it.
Optionally, the admin portal exposes per-origin protection parameters you can tune to match your risk and performance profile — including the frequency of device-key rotation. The defaults are production-safe; adjust them only as needed.
8. CSP considerations
Injection requires a nonce-based Content-Security-Policy — relock.lua reuses the application's existing nonce, so no unsafe-inline is needed. Hash-based CSP is incompatible. Polymorphic BLOB execution (Product 2) additionally requires WebAssembly directives:
script-src 'nonce-{nonce}' 'wasm-unsafe-eval' 'strict-dynamic' 'self' blob:;
frame-src 'self' blob:;
object-src 'none';
base-uri 'none';
9. Security properties preserved by the integration
relock.jsis served from the same origin as the application — no cross-origin trust.- The auth subrequest is internal — not reachable from the internet.
- The Relock Server never sees raw IdP tokens — only the derived anchor hash.
- Script injection is idempotent — safe behind caches or CDNs that may replay responses.
Check more resources
Relock for Fintech
A large US-based FinTech using Relock to close the post-authentication security gap for more than 5,000 employees.
Session Security Landscape 2026
Inaugural Relock session security research finds that 93% of leading SaaS applications are vulnerable to session attacks.
Relock with Auth0
The practical, provider-specific companion to get Auth0 talking to Relock correctly. Note: it's meant to be read with the Relock Integration Guide.