Auth System Reference

Complete guide to tostada’s authentication system: sessions, tokens, WebSocket auth, and security features.

Understanding auth is critical for multiplayer games — it determines player identity, prevents cheating, and enables features like leaderboards and profiles.


Overview

Tostada uses a dual authentication system:

  1. Session-based auth — For web browsers (HTTP + cookies)
  2. Token-based auth — For WebSocket connections (Phoenix.Token)

Both systems share the same user database and validation logic, ensuring consistent security across HTTP and WebSocket connections.


Architecture

┌─────────────┐
│   Browser   │
└──────┬──────┘
       │
       ├─ HTTP Requests ──────► Session Cookie ──► UserAuth Plug ──► current_scope
       │
       └─ WebSocket Conn ─────► Phoenix.Token ───► UserSocket ────► user_id assign

Key Components

ComponentPurpose
UserAuth plugFetches current user from session/remember-me cookie for HTTP requests
UserSocketAuthenticates WebSocket connections via Phoenix.Token or session cookie
UserToken schemaStores session tokens in database (for expiration and revocation)
ScopeWraps user + permissions (:admin, :user, nil)
API Controllers/api/auth/*, /api/me, /api/socket-token

Session-Based Auth (Web)

Registration Flow

Endpoint: POST /api/auth/register

Request:

{
  "email": "[email protected]",
  "password": "secure_password_123",
  "display_name": "PlayerOne"
}

Success Response (200):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "display_name": "PlayerOne",
    "is_admin": false
  }
}

What happens:

  1. Server validates email format, password strength
  2. Password hashed with bcrypt (default cost: 12)
  3. User record created in users table
  4. Session token generated and stored in users_tokens table
  5. Token placed in session cookie (_tostada_web_user_session)
  6. User returned in response

Login Flow

Endpoint: POST /api/auth/login

Request:

{
  "email": "[email protected]",
  "password": "secure_password_123"
}

Success Response (200):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "display_name": "PlayerOne",
    "is_admin": false
  }
}

Error Response (401):

{
  "error": "invalid_credentials"
}

What happens:

  1. Server looks up user by email
  2. Password verified with Bcrypt.verify_pass/2
  3. If valid, new session token generated
  4. Old sessions for this user remain valid (no automatic logout from other devices)
  5. Session cookie set with new token

Logout Flow

Endpoint: POST /api/auth/logout

Response:

{
  "ok": true
}

What happens:

  1. Session token deleted from database
  2. Session cookie cleared
  3. Remember-me cookie deleted (if exists)
  4. If LiveView session exists, disconnect event broadcast to close it

Remember Me

When a user logs in, they can opt for “remember me” functionality (enabled by default in tostada).

Cookie: _tostada_web_user_remember_me

Lifetime: 14 days

How it works:

  • If session cookie expires, the remember-me cookie is checked
  • If valid, user is automatically logged back in
  • New session token generated, cookies refreshed
  • Seamless re-authentication without requiring login

Security:

  • Cookie is signed (tamper-proof)
  • Uses same_site: "Lax" (CSRF protection)
  • Token stored in database (can be revoked)

Session Reissue

To prevent indefinite session validity, tokens are reissued periodically.

Reissue Age: 7 days (configurable in user_auth.ex)

How it works:

  1. On each request, token age is checked
  2. If token is >7 days old, a new token is generated
  3. Old token deleted from database
  4. Session and remember-me cookies updated with new token

Why: Limits the window for session hijacking. If an attacker steals a session token, it becomes invalid after 7-14 days.


WebSocket Auth (Channels)

WebSocket connections require authentication to identify the user and prevent unauthorized access.

Dual Auth Methods

UserSocket supports two authentication methods:

  1. Phoenix.Token (recommended for native clients)
  2. Session Cookie (automatic for web browsers)

Method 1: Phoenix.Token

Use case: Native apps, Electron, React Native, Unity WebGL, etc.

Flow:

1. Client logs in via HTTP → receives session cookie
2. Client requests socket token: GET /api/socket-token
3. Server returns short-lived token (valid 60 seconds)
4. Client connects to WebSocket with token param
5. UserSocket validates token, extracts user_id

Client example (JavaScript):

// Step 1: Login via HTTP
const response = await fetch('/api/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password }),
  credentials: 'include'  // Important: includes session cookie
});

// Step 2: Get socket token
const tokenResponse = await fetch('/api/socket-token', {
  credentials: 'include'  // Uses session cookie
});
const { token } = await tokenResponse.json();

// Step 3: Connect WebSocket with token
const socket = new Socket('/socket', {
  params: { token }
});
socket.connect();

Token validity: 60 seconds (enough to establish connection, not long enough to be stolen and reused)


Method 2: Session Cookie

Use case: Web browsers (default for tostada SvelteKit client)

Flow:

1. User logs in via HTTP → session cookie set
2. Browser automatically sends cookie with WebSocket upgrade request
3. UserSocket reads cookie, extracts session token
4. Token validated against database
5. user_id assigned to socket

Client example (tostada’s default):

import { Socket } from 'phoenix';

// No token needed! Browser sends session cookie automatically
const socket = new Socket('/socket', {});
socket.connect();

Why it works: Browsers automatically include cookies in WebSocket upgrade requests when connecting to the same origin.


UserSocket Implementation

Located at server/lib/tostada_web/channels/user_socket.ex:

def connect(%{"token" => token}, socket, _connect_info) do
  # Token-based auth (Phoenix.Token)
  case Phoenix.Token.verify(TostadaWeb.Endpoint, "user socket", token, max_age: 60) do
    {:ok, user_id} ->
      {:ok, assign(socket, :user_id, user_id)}
    {:error, _reason} ->
      :error
  end
end

def connect(_params, socket, connect_info) do
  # Session cookie-based auth
  # (reads cookie from connect_info, validates against database)
end

Socket assigns:

  • socket.assigns.user_id — Available in all channel callbacks
  • Use this to authorize channel joins, validate game actions, etc.

API Endpoints

GET /api/me

Returns current user profile.

Response (authenticated):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "display_name": "PlayerOne",
    "is_admin": false
  }
}

Response (not authenticated):

{
  "error": "not_authenticated"
}

Status: 401

Use case: Check if user is logged in, fetch profile data.


GET /api/socket-token

Generate a short-lived token for WebSocket authentication.

Response (authenticated):

{
  "token": "SFMyNTY.g2gDYQFuBgBPrH..."
}

Response (not authenticated):

{
  "error": "not_authenticated"
}

Status: 401

Token validity: 60 seconds

Use case: Native clients, dev proxy scenarios where cookies aren’t forwarded.


Admin System

Tostada includes a simple admin permission system.

Admin Flag

Users have an is_admin boolean field.

Database:

ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;

Granting admin:

# In IEx console (iex -S mix phx.server)
user = Tostada.Accounts.get_user_by_email("[email protected]")
Tostada.Accounts.update_user(user, %{is_admin: true})

Scope System

The Scope module wraps user + permissions:

%Scope{
  user: %User{},
  permissions: [:admin]  # or [:user] for regular users, or [] for guests
}

In controllers:

def show(conn, _params) do
  case conn.assigns.current_scope.permissions do
    [:admin] -> # Admin-only action
    [:user] -> # Regular user action
    [] -> # Guest (not authenticated)
  end
end

Admin-Only Routes

The RequireAdmin plug restricts access:

# In router.ex
scope "/admin", TostadaWeb.Admin do
  pipe_through [:browser, :require_admin]

  live "/users", UserLive.Index
end

Redirect: Non-admins are redirected to / with an error message.


Security Features

Password Hashing

  • Algorithm: bcrypt
  • Cost: 12 (default in Comeonin)
  • Timing: ~100-200ms per hash (prevents brute force)

In code:

Bcrypt.hash_pwd_salt(password)  # Registration
Bcrypt.verify_pass(password, hash)  # Login

Token Storage

Session tokens are stored in the database, not just signed cookies.

Why:

  • Allows individual session revocation (logout)
  • Enables “view all sessions” feature (future)
  • Enforces expiration (14 days max)

Table: users_tokens

Columns:

  • token (binary, hashed)
  • context (“session”, “confirm”, “reset_password”)
  • user_id (foreign key)
  • inserted_at (for expiration check)
  • authenticated_at (preserves original login time across reissues)

CSRF Protection

  • Session cookies use same_site: "Lax" (prevents cross-site attacks)
  • Phoenix’s built-in CSRF token validation on state-changing requests
  • API endpoints exempt from CSRF (stateless, token-based)

Connection Limits

Phoenix.Token max age prevents token reuse:

  • Socket tokens: 60 seconds
  • Email confirmation: 7 days
  • Password reset: 1 day (not implemented by default, but configured)

Sudo Mode

Not implemented by default, but tostada includes the plug:

# In controller
plug :require_sudo_mode when action in [:delete_account, :change_password]

How it works:

  • User must re-enter password for sensitive actions
  • “Sudo session” lasts 20 minutes
  • After expiry, password required again

Enable in router:

pipe_through [:browser, :require_authenticated_user, :require_sudo_mode]

Game-Specific Auth Patterns

Authorizing Channel Joins

Prevent players from joining rooms they don’t have access to:

# In game_channel.ex
def join("game:" <> room_id, _payload, socket) do
  user_id = socket.assigns.user_id

  case GameServer.authorize_join(room_id, user_id) do
    :ok -> {:ok, socket}
    {:error, reason} -> {:error, %{reason: reason}}
  end
end

Validating Game Actions

Check user_id in every channel handler:

def handle_in("move", %{"direction" => direction}, socket) do
  user_id = socket.assigns.user_id
  GameServer.queue_input(user_id, direction)
  {:noreply, socket}
end

Why: Even if a client is compromised, they can only send actions for their own user_id (server validates).


Leaderboards and Profiles

Use user_id from socket/session to associate game data:

# In GameServer
defmodule Tostada.GameServer do
  def record_score(user_id, score) do
    Tostada.Leaderboard.insert_score(user_id, score)
  end
end

Database:

CREATE TABLE scores (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES users(id),
  score INTEGER NOT NULL,
  inserted_at TIMESTAMP
);

Common Patterns

Checking if User is Logged In (Client)

async function isLoggedIn(): Promise<boolean> {
  try {
    const response = await fetch('/api/me', { credentials: 'include' });
    return response.ok;
  } catch {
    return false;
  }
}

Protecting Routes (Server)

# In router.ex
scope "/app", TostadaWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/*path", SPAController, :index
end

Effect: Unauthenticated users redirected to login page.


Getting Current User in LiveView

def mount(_params, _session, socket) do
  case socket.assigns.current_scope do
    %{user: user} when not is_nil(user) ->
      {:ok, assign(socket, :user, user)}
    _ ->
      {:ok, redirect(socket, to: ~p"/users/login")}
  end
end

Troubleshooting

“not_authenticated” Error

Cause: Session cookie not sent or expired.

Solutions:

  • Ensure credentials: 'include' in fetch requests
  • Check if session cookie exists (browser DevTools → Application → Cookies)
  • Try logging in again

WebSocket Won’t Connect

Cause: Invalid token or cookie.

Solutions:

  • For token-based: Fetch a new socket token (/api/socket-token)
  • For cookie-based: Check if session cookie exists
  • Verify WebSocket URL matches server origin

“unauthorized” Channel Join

Cause: User not authenticated or doesn’t have access to channel topic.

Solutions:

  • Check socket.assigns.user_id is set (log it in join/3)
  • Verify authorization logic in channel (is user allowed in this room?)

Next Steps


Questions about auth? Check the Phoenix Auth documentation for advanced patterns.