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.fetch_current_scope_for_user plugLoads the current user from the session cookie (or remember-me cookie) and assigns :current_scope
UserAuth.log_in_api_user / log_out_api_userMints/clears the server-side session token; sets/clears the HttpOnly cookie
UserSocketAuthenticates WebSocket handshakes via short-lived Phoenix.Token (preferred) or the session cookie
UserToken schemaStores session and password-reset tokens; allows individual session revocation
ScopeWraps the current user; nil when unauthenticated
RequireAdmin plugReturns JSON 403 for non-admin requests on admin-scoped pipelines
API Controllers/api/auth/{register,login,logout,forgot-password,reset-password}, /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 the HttpOnly session cookie (_<app_name>_key, e.g. _tostada_key)
  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)

Password Reset Flow

Two JSON endpoints handle password reset. Email delivery uses Swoosh; in dev, emails appear at /dev/mailbox and in prod you configure an SMTP/SES/Postmark adapter in config/runtime.exs.

POST /api/auth/forgot-password — initiates the reset.

Request:

{
  "email": "[email protected]",
  "reset_url": "https://app.example.com/reset-password?token={token}"
}

The {token} placeholder is substituted with the generated token. reset_url defaults to /reset-password?token={token} if omitted.

Response is always 200 even if the email doesn’t exist — this prevents account enumeration.

POST /api/auth/reset-password — completes the reset using the token from the email link.

Request:

{
  "token": "<token from email>",
  "password": "new_secure_password"
}

Success (200): { "ok": true }

Failure (422): { "error": "invalid_or_expired_token" } or { "error": "validation_failed", "details": {...} }

The reset invalidates all of the user’s existing session tokens — any active sessions on other devices are kicked.


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 Endpoints

The RequireAdmin plug halts the request with a JSON 403 if the current scope isn’t an admin:

# In router.ex
pipeline :api do
  plug :accepts, ["json"]
  plug :fetch_session
  plug :fetch_current_scope_for_user
end

pipeline :require_admin do
  plug TostadaWeb.Plugs.RequireAdmin
end

scope "/api/admin", TostadaWeb.Api.Admin do
  pipe_through [:api, :require_admin]

  # your admin-only JSON endpoints
end

Tostada doesn’t ship admin UI on the server — the SPA is responsible for rendering an admin section conditionally on the is_admin flag returned by /api/me. The server’s job is to enforce, not render.


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 for email-context tokens; raw for session tokens)
  • context ("session" or "reset_password")
  • user_id (foreign key)
  • inserted_at (for expiration check)
  • authenticated_at (preserves original login time across session reissues)
  • sent_to (email recipient — used for reset-password tokens)

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

Token validity bounds prevent reuse:

  • Session tokens: 14 days, reissued every 7 days of activity
  • Socket bearer tokens (Phoenix.Token): 60 seconds — enough to handshake, too short to be stolen and replayed later
  • Password reset tokens: 7 days

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 JSON Endpoints (Server)

Controllers pattern-match on conn.assigns.current_scope and return 401 when there’s no authenticated user:

def me(conn, _params) do
  case conn.assigns[:current_scope] do
    nil ->
      conn
      |> put_status(:unauthorized)
      |> json(%{error: "not_authenticated"})

    scope ->
      json(conn, %{user: safe_user(scope.user)})
  end
end

The fetch_current_scope_for_user plug in the :api pipeline always assigns :current_scope (with a nil user when unauthenticated), so the pattern-match is safe.


Protecting Routes (Client)

The SPA shell at /app serves to anyone — there’s no server-side gate. The client checks /api/me on mount and renders either the unauthenticated landing or the authenticated view accordingly:

// SvelteKit hooks.client.ts or React useEffect
async function bootstrap() {
  const res = await fetch('/api/me', { credentials: 'include' });
  if (res.ok) {
    const { user } = await res.json();
    setCurrentUser(user);          // render the app
  } else {
    redirectTo('/login');           // render the login form
  }
}

This keeps the SPA shell deliverable in one round-trip and lets the client own its own routing without round-tripping through Phoenix for protected views.


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.