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.
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.
What goes in the token
The facts in the payload are called claims. Each one is a statement about the person currently logged in.
| Claim | Required | What it is |
|---|---|---|
| sub | Yes | 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. |
| exp | Yes | 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. |
| No | Their email address, so the agent can reference it and attach it to escalations. | |
| name | No | Their display name, so the agent can greet them properly. |
| plan | No | Whatever 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.
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
| Environment | Where |
|---|---|
| Anything with env vars | An environment variable such as AISUPP_IDENTITY_SECRET, set in your systemd unit, Docker config, or host control panel. This is the best option. |
| WordPress | A define() in wp-config.php. That file is executed rather than served, so its contents are not visible to visitors. |
| Laravel | Your .env file, which sits above public/, read through a config entry rather than env() directly. |
| Rails | ENV, or encrypted credentials. |
| cPanel or Plesk shared hosting | A file outside public_html, read by your code, or the panel's environment variable feature if it has one. |
Where not to keep it
- Anywhere in JavaScript, or anywhere it is printed into a page
- A committed file in a public repository
- A plain
.txt,.jsonor.envinside your web root, where a browser can simply request it - Inside
wp-content/,public/, or any directory served as static files
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.
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.
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
- In your AISupportForge portal, open Install and copy your signing secret.
- In WordPress, go to Settings, then AI Support Agent.
- Paste it into Recognise logged-in customers and save.
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:
- You are not using our plugin. If you pasted the agent snippet into your theme by hand, there is nothing running server side to build a token, so you have to build it yourself. Use the code below.
-
You need claims the plugin does not send. It sends
sub(the WordPress user ID),emailandname. If your agent needs something like a membership tier or subscription level from another plugin, in theplanclaim, the plugin cannot know about that and you will need the code below. Tell us if you hit this, because a filter hook would be a better answer than making you write this.
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.
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
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();
});
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.
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 } %>
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.
When it does not work
Almost every failure is one of these five, in rough order of how often we see them.
| Symptom | Usual 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.