Authentication
How your server hands a browser permission to connect, without the browser ever holding your API key.
Why not just put the key in the frontend
An API key is secret.keyId and it is your account. Anyone who reads it out of
a bundle, a source map or a devtools network tab can publish to every channel,
read every history, and keep doing it until you notice and rotate.
A TokenRequest is what you hand out instead: signed by your server with the
key, scoped to the channels and operations one user may touch, and dead after
its ttl. The browser exchanges it for a token and connects with that.
Your server signs it locally — there is no call to Blackevin. Your login path must not depend on us being reachable.
The flow
browser your server Blackevin
│ GET /api/blackevin-token │ │
├────────────────────────────►│ │
│ │ createTokenRequest() │
│ │ (local HMAC, no network) │
│◄────────────────────────────┤ │
│ POST /keys/:keyName/requestToken │
├───────────────────────────────────────────────────────►│
│◄───────────────────────────────────────────────────────┤
│ connect with the token │
├───────────────────────────────────────────────────────►│The last two steps are the SDK's job. You give it authUrl and it does the rest.
Your server
import * as Blackevin from '@blackevin/client';
const rest = new Blackevin.Rest({ key: process.env.BLACKEVIN_KEY });
app.get('/api/blackevin-token', async (req, res) => {
const user = await currentUser(req);
res.json(
await rest.auth.createTokenRequest({
clientId: String(user.id),
ttl: 3_600_000,
capability: {
[`chat:team-${user.teamId}`]: ['subscribe', 'publish'],
[`presence:chat:team-${user.teamId}`]: ['presence'],
[String(user.id)]: ['subscribe', 'publish'],
'announcements:global': ['subscribe'],
},
})
);
});Your browser
const realtime = new Blackevin.Realtime({
authUrl: '/api/blackevin-token',
clientId: String(user.id),
});That is the whole client side. The SDK fetches the TokenRequest, exchanges it for a token, connects with it, and re-fetches on every reconnect — so a token expiring mid-session is not something you handle.
Without a Node backend
There is no Ruby SDK yet, so a Rails app signs the request itself. The whole contract is an HMAC-SHA256 over six fields:
require "openssl"
require "base64"
require "json"
require "securerandom"
class BlackevinService
DEFAULT_TTL_MS = 1.hour.in_milliseconds
def initialize(client_id:, capability:, ttl: nil)
@client_id = client_id.presence || SecureRandom.hex
@capability = capability
@ttl = ttl
end
def create_token_request
request = {
keyName: key_name,
ttl: @ttl || DEFAULT_TTL_MS,
capability: @capability.to_json,
clientId: @client_id,
timestamp: (Time.current.to_f * 1000).to_i,
nonce: SecureRandom.hex(16)
}
request.merge(mac: sign(request))
end
private
def sign(request)
text = [
request[:keyName],
request[:ttl],
request[:capability],
request[:clientId],
request[:timestamp],
request[:nonce]
].map { |field| "#{field}\n" }.join
Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", secret, text))
end
def secret = access_key.rpartition(".").first
def key_name = access_key.rpartition(".").last
def access_key = @access_key ||= ENV.fetch("BLACKEVIN_ACCESS_KEY")
endThe field order and the trailing newline on each are the wire contract — the
node recomputes exactly this string to verify. Reorder them and every token
breaks with invalid token request mac, which says nothing about why.
Two things to check when a token is rejected
ttlis milliseconds, not seconds. An Ably-shaped1.hourin seconds reads as 3.6 seconds here, and the token expires before the page finishes loading.capabilityis signed as a JSON string, not as an object. Sign the exact bytes you send, or the MAC will not match.
Scope it down
capability maps a channel pattern to the operations allowed on it. Grant the
narrowest set that works — a token scoped to chat:team-42 => [subscribe]
cannot publish anywhere, so it is worth very little to whoever lifts it out of
the browser it was minted for.
{
'orders:*': ['subscribe'],
'chat:room-7': ['subscribe', 'publish'],
'presence:chat:room-7': ['presence'],
}* alone is every channel. It is the default when you pass no capability, and
it is rarely what you want for a browser. The full operation list and the
matching rules are in capabilities.
The other ways in
authUrl is the common case. The SDK takes three others, all Ably-shaped:
| option | when |
|---|---|
key | server-side code you trust with the key |
token | you already have a JWT and will manage its lifetime yourself |
authCallback | you need to fetch the credential with your own logic |
new Blackevin.Realtime({
authCallback: (tokenParams, cb) => {
fetch('/api/blackevin-token')
.then(r => r.json())
.then(tokenRequest => cb(null, tokenRequest))
.catch(err => cb(err, null));
},
});The callback may hand back a JWT string, a TokenDetails, or a TokenRequest —
the SDK works out which and exchanges it if it needs to.
Next
Tokens covers what comes back, how long it lives, and the JWT's claims.