DEVELOPER DOCS

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.com with 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.js or 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, plus Origin for 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 that X-Key-Token was 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.js into HTML responses, derives the session anchor from the ALB-injected token, forwards proof + anchor headers to the Gatekeeper via an internal auth_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/authn subrequest; 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

VariableMeaning
${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:

ProviderAnchor claimsProvider-side actionSupporting Docs
Oktasub + auth_timenone neededOkta setup →
Microsoft Entra IDunique_name + sidnone neededEntra ID setup →
Auth0namespaced email + auth_timePost-Login ActionAuth0 setup →
Ping (PingOne)sub + sidnone neededPing 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.js is 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.