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.fetch_current_scope_for_user plug | Loads the current user from the session cookie (or remember-me cookie) and assigns :current_scope |
UserAuth.log_in_api_user / log_out_api_user | Mints/clears the server-side session token; sets/clears the HttpOnly cookie |
UserSocket | Authenticates WebSocket handshakes via short-lived Phoenix.Token (preferred) or the session cookie |
UserToken schema | Stores session and password-reset tokens; allows individual session revocation |
Scope | Wraps the current user; nil when unauthenticated |
RequireAdmin plug | Returns 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:
- 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 the HttpOnly session cookie (
_<app_name>_key, e.g._tostada_key) - 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)
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:
- 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 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_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.