Skip to main content

Overview

BloxChat uses JWT (JSON Web Token) authentication to secure API endpoints. The authentication system integrates with Roblox’s user profiles and provides a secure verification flow for linking Roblox users to chat sessions.
All authentication logic is implemented in packages/api/src/routers/auth.ts and packages/api/src/context.ts.

Authentication Flow

The complete authentication process follows this sequence:
1

Begin Verification

Client calls auth.beginVerification to receive a verification code and session ID.
const { sessionId, code, placeId } = await client.auth.beginVerification.mutate();
// Display code to user: "Enter code 123456 in the game"
2

User Enters Code in Game

User enters the verification code inside the Roblox game. The game server validates the code with the API.
3

Game Server Completes Verification

Game server calls auth.completeVerification with the verification secret, code, and Roblox user ID.
await client.auth.completeVerification.mutate({
  code: '123456',
  robloxUserId: '123456789'
});
This endpoint requires the x-verification-secret header and is only called by trusted game servers.
4

Client Polls for Completion

Client repeatedly calls auth.checkVerification until the session is verified.
const result = await client.auth.checkVerification.mutate({ sessionId });

if (result.status === 'verified') {
  const { jwt, user } = result;
  // Store JWT for future requests
}
5

Authenticated Requests

Client includes JWT in the Authorization header for all subsequent requests.
headers: {
  authorization: `Bearer ${jwt}`
}

JWT Token Format

Token Structure

JWT tokens are signed using HS256 (HMAC-SHA256) with a secret key defined in env.JWT_SECRET. The payload contains:
Token Payload (JwtUser)
{
  robloxUserId: string;      // Roblox user ID (numeric string)
  name: string;              // Roblox display name
  displayName: string;       // Roblox username
  avatarUrl: string;         // Roblox avatar thumbnail URL
}

Token Expiration

Tokens expire after 1 hour (JWT_EXPIRY = "1h").
packages/api/src/routers/auth.ts:31
const JWT_EXPIRY = "1h";
After 1 hour, clients must refresh their token using auth.refresh to maintain their session.

Token Verification

The server extracts and verifies JWT tokens in the context creation phase:
import jwt from 'jsonwebtoken';
import { env } from './config/env';
import { JwtUser } from './types';

export async function createContext({
  req,
}: {
  req: IncomingMessage;
  res: ServerResponse;
}) {
  let user: JwtUser | null = null;

  const authHeader = req.headers['authorization'];
  const token =
    authHeader && authHeader.startsWith('Bearer ')
      ? authHeader.split(' ')[1]
      : undefined;

  if (token) {
    try {
      const payload = jwt.verify(token, env.JWT_SECRET) as JwtUser;
      user = payload;
    } catch {}
  }

  return { user, headers: req.headers };
}

Token Refresh Mechanism

When a token expires, clients can refresh it using the auth.refresh endpoint:
Refresh Example
try {
  const { jwt, user } = await client.auth.refresh.mutate({ 
    jwt: expiredToken 
  });
  
  // Store new token
  localStorage.setItem('jwt', jwt);
  console.log('Token refreshed for', user.name);
} catch (error) {
  // Token is invalid or user doesn't exist
  // Redirect to login
}
refresh: publicProcedure
  .input(z.object({ jwt: z.string() }))
  .mutation(async ({ input }) => {
    let payload: unknown;
    try {
      payload = jwt.verify(input.jwt, env.JWT_SECRET, {
        ignoreExpiration: true,  // Allow expired tokens
      });
    } catch {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: 'Invalid session',
      });
    }

    const { robloxUserId } = parsedPayload.data;
    
    // Fetch fresh user data from Roblox
    const user = await fetchRobloxUserProfile(robloxUserId);
    return buildSession(user);
  })
The refresh endpoint accepts expired tokens (ignoreExpiration: true) but validates the signature and fetches fresh user data from Roblox.

Protected vs Public Procedures

BloxChat defines three procedure types with different authentication requirements:

Public Procedures

No authentication required:
packages/api/src/trpc.ts:48
export const publicProcedure = t.procedure;
Examples:
  • auth.beginVerification - Start verification flow
  • auth.checkVerification - Poll verification status
  • auth.refresh - Refresh expired token
  • chat.limits - Get channel rate limits

Protected Procedures

Require valid JWT token:
packages/api/src/trpc.ts:11-20
export const isAuthed = middleware(async ({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });

  return next({
    ctx: {
      ...ctx,
      user: ctx.user!,
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);
Examples:
  • chat.publish - Send chat message
  • Any endpoint requiring user identity

Game Verification Procedures

Require verification secret header:
packages/api/src/trpc.ts:22-36
const hasVerificationSecret = middleware(async ({ ctx, next }) => {
  const secretHeader = ctx.headers["x-verification-secret"];
  const providedSecret = Array.isArray(secretHeader)
    ? secretHeader[0]
    : secretHeader;

  if (!providedSecret || !isValidVerificationSecret(providedSecret)) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Invalid verification secret",
    });
  }

  return next();
});

export const gameVerificationProcedure = t.procedure.use(hasVerificationSecret);
Examples:
  • auth.completeVerification - Complete verification from game server
The verification secret uses timing-safe comparison (crypto.timingSafeEqual) to prevent timing attacks.

Including JWT in Requests

HTTP Requests

Include the token in the Authorization header:
POST /chat.publish HTTP/1.1
Host: localhost:3000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

tRPC Client Configuration

import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';

export const trpc = createTRPCReact<AppRouter>();

export const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
      headers: () => {
        const token = localStorage.getItem('bloxchat-jwt');
        return {
          authorization: token ? `Bearer ${token}` : '',
        };
      },
    }),
  ],
});

Security Considerations

Store JWT tokens securely:
  • Web apps: Use localStorage or sessionStorage (vulnerable to XSS)
  • Better: Use HTTP-only cookies (not currently implemented)
  • Game servers: Use environment variables for verification secret
packages/api/src/trpc.ts:38-46
function isValidVerificationSecret(providedSecret: string) {
  const expectedSecret = env.VERIFICATION_SECRET;
  if (providedSecret.length !== expectedSecret.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(providedSecret),
    Buffer.from(expectedSecret),
  );
}
  • Uses crypto.timingSafeEqual to prevent timing attacks
  • Length check before comparison
  • Never expose VERIFICATION_SECRET to clients
The context creation silently fails invalid tokens:
if (token) {
  try {
    const payload = jwt.verify(token, env.JWT_SECRET) as JwtUser;
    user = payload;
  } catch {}
}
This allows public procedures to work without authentication while protected procedures can check ctx.user.
Authentication endpoints have strict rate limits:
  • Refresh: 4 requests/hour per user
  • Game verification: 20 requests/minute per user
  • Check verification: 60 requests/minute per session
See packages/api/src/routers/auth.ts:32-37 for rate limit constants.

Error Handling

Common authentication errors:
// Missing or invalid token
{
  code: 'UNAUTHORIZED',
  message: 'Invalid session'
}

// Client should redirect to login

Complete Authentication Example

Here’s a full example of the authentication flow:
Complete Flow
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@bloxchat/api';

// 1. Create client
const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
      headers: () => {
        const jwt = localStorage.getItem('bloxchat-jwt');
        return jwt ? { authorization: `Bearer ${jwt}` } : {};
      },
    }),
  ],
});

// 2. Begin verification
const { sessionId, code, placeId } = await client.auth.beginVerification.mutate();
console.log(`Enter code ${code} in game (Place ID: ${placeId})`);

// 3. Poll for completion
let verified = false;
while (!verified) {
  await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
  
  const result = await client.auth.checkVerification.mutate({ sessionId });
  
  if (result.status === 'verified') {
    localStorage.setItem('bloxchat-jwt', result.jwt);
    console.log('Logged in as', result.user.name);
    verified = true;
  } else if (result.status === 'expired') {
    console.error('Verification expired, please restart');
    break;
  }
}

// 4. Make authenticated requests
const message = await client.chat.publish.mutate({
  content: 'Hello, BloxChat!',
  channel: 'global',
});

// 5. Refresh token when needed (before expiration)
setInterval(async () => {
  try {
    const jwt = localStorage.getItem('bloxchat-jwt');
    if (!jwt) return;
    
    const { jwt: newJwt } = await client.auth.refresh.mutate({ jwt });
    localStorage.setItem('bloxchat-jwt', newJwt);
    console.log('Token refreshed');
  } catch (error) {
    console.error('Refresh failed, please re-login');
    localStorage.removeItem('bloxchat-jwt');
  }
}, 50 * 60 * 1000); // Refresh every 50 minutes (before 1h expiration)

Next Steps

Begin Verification

Start the authentication flow

Complete Verification

Complete verification from Roblox game

Check Verification

Poll verification status

Refresh Token

Refresh expired JWT tokens

Build docs developers (and LLMs) love