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

server.ts
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

app.ts
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:

app/services/blackevin_service.rb
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")
end

The 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

  • ttl is milliseconds, not seconds. An Ably-shaped 1.hour in seconds reads as 3.6 seconds here, and the token expires before the page finishes loading.
  • capability is 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:

optionwhen
keyserver-side code you trust with the key
tokenyou already have a JWT and will manage its lifetime yourself
authCallbackyou 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.

On this page