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:
- Session-based auth — For web browsers (HTTP + cookies)
- 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
| Component | Purpose |
|---|---|
UserAuth plug | Fetches current user from session/remember-me cookie for HTTP requests |
UserSocket | Authenticates WebSocket connections via Phoenix.Token or session cookie |
UserToken schema | Stores session tokens in database (for expiration and revocation) |
Scope | Wraps 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:
- Server validates email format, password strength
- Password hashed with bcrypt (default cost: 12)
- User record created in
userstable - Session token generated and stored in
users_tokenstable - Token placed in session cookie (
_tostada_web_user_session) - 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:
- Server looks up user by email
- Password verified with
Bcrypt.verify_pass/2 - If valid, new session token generated
- Old sessions for this user remain valid (no automatic logout from other devices)
- Session cookie set with new token
Logout Flow
Endpoint: POST /api/auth/logout
Response:
{
"ok": true
} What happens:
- Session token deleted from database
- Session cookie cleared
- Remember-me cookie deleted (if exists)
- If LiveView session exists,
disconnectevent 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:
- On each request, token age is checked
- If token is >7 days old, a new token is generated
- Old token deleted from database
- 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:
- Phoenix.Token (recommended for native clients)
- 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_idis set (log it injoin/3) - Verify authorization logic in channel (is user allowed in this room?)
Next Steps
- Use auth in your game: Channels + Stores Guide
- See API structure: Project Structure
- Deploy with auth: Makefile Reference
Questions about auth? Check the Phoenix Auth documentation for advanced patterns.