documentation

Recognising your logged-in customers

By default your agent answers everyone the same way, because it has no idea who it is talking to. That is fine for most sites, and it is how your agent works out of the box with no setup. This page covers the optional step of telling it who a logged-in visitor is, so it can answer about their own account. Budget about twenty minutes for a developer who knows your stack.


You almost certainly do not need this page

Installing your agent takes no code at all. On WordPress it is our plugin and a site key, about six clicks. Everything on this page is an optional extra for one specific case: letting the agent answer questions about a customer's own account, like "where is my order". If you do not need that, close this tab. Nothing here is required, and none of it is part of a normal setup.

The optional extra does need a server that you control. If your site runs on WordPress, Laravel, Django, Flask, Rails, Node, or anything else where you can run code on the server, you are fine.

If your site is built on Wix, Squarespace, or a Shopify theme, you cannot use this. There is no server of yours to vouch for who someone is, and no safe way to do it from the browser. Skip this page. Everything else about your agent works normally without it.

01

Why it works this way

The obvious approach would be for the agent to ask "what is your email address?". The problem is that anyone can type anyone else's. If the agent then answered account questions, your customers' order history would be one guess away from a stranger.

So instead, your server vouches for the visitor. It already knows who is logged in, because that is what a login session is. It writes down who they are, signs that statement with a secret only it and we know, and hands the signed statement to the agent. We check the signature. A visitor cannot forge one, and cannot talk their way past it.

The signed statement is a JSON Web Token, or JWT. If you have not used one before, it is just three chunks of text joined by dots: a header saying how it was signed, a payload of facts, and a signature over the first two.

You never enter any customer's details anywhere. You add the code once, and from then on it fills itself in for every logged-in visitor on every page load. There is no user list to import and nothing to keep in sync.

02

What goes in the token

The facts in the payload are called claims. Each one is a statement about the person currently logged in.

ClaimRequiredWhat it is
subYes Short for "subject": your own ID for that customer. Whatever your database uses, such as 4417 or usr_a91f. This is what tells us that two conversations a month apart are the same person.
expYes When this token stops being valid, as a Unix timestamp in seconds. Must be 24 hours or less. A fresh token is made on every page load, so an hour is plenty.
emailNoTheir email address, so the agent can reference it and attach it to escalations.
nameNoTheir display name, so the agent can greet them properly.
planNoWhatever tier or group they are on, if that changes the answers they should get.

Send only what the agent actually needs. Anything you put in the token is available to it, so there is no reason to include a field it will never use.

03

The signing secret

Your signing secret is in your portal, under Install. It is the shared secret your server uses to sign tokens, and it is the only reason we believe a token came from you.

Treat it like a password to your customers' accounts

Anyone who has this secret can impersonate any of your customers to your agent. They can mint a token claiming any sub they like and read whatever account-aware answers that person would get. It must never reach a browser. Only the tokens it produces do.

Where to keep it

EnvironmentWhere
Anything with env varsAn environment variable such as AISUPP_IDENTITY_SECRET, set in your systemd unit, Docker config, or host control panel. This is the best option.
WordPressA define() in wp-config.php. That file is executed rather than served, so its contents are not visible to visitors.
LaravelYour .env file, which sits above public/, read through a config entry rather than env() directly.
RailsENV, or encrypted credentials.
cPanel or Plesk shared hostingA file outside public_html, read by your code, or the panel's environment variable feature if it has one.

Where not to keep it

Rotating is safe. Add the new secret and deploy, then revoke the old one in the portal. Both work in the meantime, so there is no window where your customers stop being recognised.

04

Getting the token to the widget

There are two ways, and the first is better.

Preferred: an attribute on the script tag

Put the token straight on the tag. The widget reads it the moment it runs, so there is no timing to get wrong.

<script src="https://aisupportforge.com/w/YOUR_SITE_KEY.js" async
        data-asf-token="<the token your server just made>"></script>

Alternative: call identify() later

Use this if the token is not available when the tag is rendered, for example in a single page application after a sign-in. The widget script is asynchronous, so it may not have loaded yet. Calling window.asf.identify() directly will work sometimes and silently do nothing other times, which is the worst kind of bug. Wait for it:

(function (t) {
  (function go() {
    if (window.asf && window.asf.identify) return window.asf.identify(t);
    setTimeout(go, 200);
  })();
})("<the token>");

If you installed through our WordPress plugin, ignore both of these. From version 1.5.0 the plugin builds the token and attaches it to the tag itself, using the first and safer route. You do not write either snippet. See WordPress below.

05

WordPress

On WordPress there is nothing to write

If you use our plugin, the plugin does all of this for you. No PHP, no mu-plugins folder, no theme files. It already knows who is logged in, so it builds the signed token and attaches it to the agent for you.

The whole thing

That is the entire setup. Logged-in visitors are now identified to the agent, and anonymous visitors carry no token at all, exactly as before.

Optional, and a little safer

The settings field stores your secret in the WordPress database. If you would rather it were not there, put it in wp-config.php instead, above the line that says "That's all, stop editing":

define( 'AISUPP_IDENTITY_SECRET', 'paste-your-secret-here' );

If you set this up before version 1.6.0 the constant was called ASF_IDENTITY_SECRET. That name still works, so there is nothing you have to change.

If you set this up before version 1.6.0 the constant was called AISUPP_IDENTITY_SECRET. That name still works, so there is nothing you have to change.

The plugin uses that automatically and ignores the settings box. A constant is not readable from the database and does not travel in a database export, so it is the better home for it. This is one line in one file, and it is the only file editing anywhere in the WordPress route.

When you do still need code

Two cases, and only two:

If neither of those is you, skip to Checking it worked.

The manual route, for those two cases

Put the secret in wp-config.php as above, then add this as a file in wp-content/mu-plugins/, which loads automatically and survives theme changes. Create the folder if it does not exist.

<?php
// wp-content/mu-plugins/asf-identity.php

function asf_identity_token() {
    if ( ! is_user_logged_in() || ! defined( 'AISUPP_IDENTITY_SECRET' ) ) {
        return null;
    }
    $user = wp_get_current_user();

    $b64 = static function ( $bytes ) {
        return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' );
    };

    $header  = $b64( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) );
    $payload = $b64( wp_json_encode( array(
        'sub'   => (string) $user->ID,
        'email' => $user->user_email,
        'name'  => $user->display_name,
        'exp'   => time() + 3600,
    ) ) );

    $input = $header . '.' . $payload;
    $sig   = $b64( hash_hmac( 'sha256', $input, AISUPP_IDENTITY_SECRET, true ) );

    return $input . '.' . $sig;
}

// Priority 21 so this runs after the agent script has been printed.
add_action( 'wp_footer', function () {
    $token = asf_identity_token();
    if ( ! $token ) {
        return;
    }
    printf(
        '<script>(function(t){(function go(){if(window.asf&&window.asf.identify)'
        . 'return window.asf.identify(t);setTimeout(go,200);})();})(%s);</script>',
        wp_json_encode( $token )
    );
}, 21 );

Add your extra claims to the $payload array if that is why you are here.

06

Laravel

Add to .env, then reference it from a config file rather than calling env() in your code, so config caching keeps working.

// config/services.php
'aisupportforge' => [
    'identity_secret' => env('AISUPP_IDENTITY_SECRET'),
],
// app/Support/AsfIdentity.php
namespace App\Support;

use Illuminate\Support\Facades\Auth;

class AsfIdentity
{
    public static function token(): ?string
    {
        if (! Auth::check()) {
            return null;
        }
        $secret = config('services.aisupportforge.identity_secret');
        if (! $secret) {
            return null;
        }
        $user = Auth::user();

        $b64 = fn (string $bytes) => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');

        $header  = $b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $payload = $b64(json_encode([
            'sub'   => (string) $user->getAuthIdentifier(),
            'email' => $user->email,
            'name'  => $user->name,
            'exp'   => time() + 3600,
        ]));

        $input = $header.'.'.$payload;

        return $input.'.'.$b64(hash_hmac('sha256', $input, $secret, true));
    }
}

In your layout Blade template:

@if ($asfToken = \App\Support\AsfIdentity::token())
<script src="https://aisupportforge.com/w/YOUR_SITE_KEY.js" async
        data-asf-token="{{ $asfToken }}"></script>
@else
<script src="https://aisupportforge.com/w/YOUR_SITE_KEY.js" async></script>
@endif
07

Node and Express

No dependencies needed; the standard crypto module is enough.

// asf-identity.js
const crypto = require('crypto');

const b64 = (bytes) =>
  Buffer.from(bytes)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');

function asfIdentityToken(user) {
  const secret = process.env.AISUPP_IDENTITY_SECRET;
  if (!secret || !user) return null;

  const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const payload = b64(
    JSON.stringify({
      sub: String(user.id),
      email: user.email,
      name: user.name,
      exp: Math.floor(Date.now() / 1000) + 3600,
    })
  );

  const input = header + '.' + payload;
  const sig = b64(crypto.createHmac('sha256', secret).update(input).digest());
  return input + '.' + sig;
}

module.exports = { asfIdentityToken };

Then pass it to whatever renders your pages:

app.use((req, res, next) => {
  res.locals.asfToken = asfIdentityToken(req.user);
  next();
});
08

Django and Flask

Standard library only. The same function works in both; only how you reach the current user differs.

# asf_identity.py
import base64
import hashlib
import hmac
import json
import os
import time


def _b64(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


def asf_identity_token(user_id, email=None, name=None, plan=None):
    secret = os.environ.get("AISUPP_IDENTITY_SECRET")
    if not secret or not user_id:
        return None

    claims = {"sub": str(user_id), "exp": int(time.time()) + 3600}
    if email:
        claims["email"] = email
    if name:
        claims["name"] = name
    if plan:
        claims["plan"] = plan

    dumps = lambda d: json.dumps(d, separators=(",", ":")).encode()
    header = _b64(dumps({"alg": "HS256", "typ": "JWT"}))
    payload = _b64(dumps(claims))
    signing_input = f"{header}.{payload}".encode()
    sig = _b64(hmac.new(secret.encode(), signing_input, hashlib.sha256).digest())

    return f"{header}.{payload}.{sig}"

In a Django template context processor:

def asf_token(request):
    u = getattr(request, "user", None)
    if not u or not u.is_authenticated:
        return {"asf_token": None}
    return {"asf_token": asf_identity_token(u.pk, u.email, u.get_full_name())}

In Flask, the same thing with flask_login.current_user.

09

Ruby on Rails

# app/helpers/asf_identity_helper.rb
require "openssl"
require "base64"

module AsfIdentityHelper
  def asf_identity_token
    return nil unless current_user

    secret = ENV["AISUPP_IDENTITY_SECRET"]
    return nil if secret.blank?

    b64 = ->(s) { Base64.urlsafe_encode64(s, padding: false) }

    header  = b64.call({ alg: "HS256", typ: "JWT" }.to_json)
    payload = b64.call({
      sub:   current_user.id.to_s,
      email: current_user.email,
      name:  current_user.try(:name),
      exp:   Time.now.to_i + 3600
    }.compact.to_json)

    input = "#{header}.#{payload}"
    "#{input}.#{b64.call(OpenSSL::HMAC.digest('SHA256', secret, input))}"
  end
end

In your layout:

<%= tag.script src: "https://aisupportforge.com/w/YOUR_SITE_KEY.js", async: true,
      data: { asf_token: asf_identity_token } %>
10

Checking it worked

Log in to your own site as a test customer, open the agent, and ask it something only a recognised customer would be told. Then open your portal, go to Conversations, and check that the conversation is attributed to that customer rather than to an anonymous visitor.

A broken token never breaks your site. If the signature does not check out, or the token has expired, we simply treat that visitor as anonymous. The agent keeps working and answers general questions. That is deliberate, but it does mean a mistake here is quiet, so confirm it rather than assuming.

11

When it does not work

Almost every failure is one of these five, in rough order of how often we see them.

SymptomUsual cause
Always anonymous exp in milliseconds instead of seconds. A JavaScript Date.now() is milliseconds; divide by 1000 and floor it. An expiry in the year 57000 is rejected the same as one in the past.
Always anonymous The secret has a trailing newline or a stray space, usually from a copy and paste or a file read. Trim it.
Works sometimes Calling identify() without waiting for the widget to load. Use the retry loop above.
Always anonymous Standard base64 instead of base64url. Replace + with -, / with _, and strip the trailing = padding, on all three parts.
Always anonymous Empty sub, because the user object was not what you expected. Log the claims you are about to sign and check.

Still stuck? Email support@aisupportforge.com with the token your server produced. It is safe to send us a token, since it expires shortly and we can already verify it. Never send us the signing secret.

aisupportforge.com How it works  ·  Pricing  ·  Contact  ·  Terms  ·  Privacy ← Back to home