Skip to main content
For apps that need a password flow — shared devices, no email access, regulatory requirements — Pylon ships built-in email + password auth. Passwords are hashed with Argon2id (the OWASP-recommended algorithm); failed logins run a dummy hash so timing doesn’t leak whether the email exists.

Register

curl -X POST https://your-app/api/auth/password/register \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "alice@example.com",
    "password": "correct-horse-battery-staple",
    "displayName": "Alice"
  }'
Response (201):
{
  "token": "pylon_a1b2c3...",
  "user_id": "usr_xyz",
  "expires_at": 1735689600
}
The user is created and signed in — no separate “verify your email” step. If you need email verification, call /api/auth/email/send-verification after sign-in. The user can keep using the app while their email is unverified; gate sensitive flows on User.emailVerified.

Validation rules

RuleEnforcement
Email contains @400 INVALID_EMAIL
Email is lowercased + trimmedServer-side normalization
Password ≥ 8 characters400 WEAK_PASSWORD
Email not already registered409 EMAIL_TAKEN
displayName defaults to the email if omitted.

Log in

curl -X POST https://your-app/api/auth/password/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "alice@example.com", "password": "correct-horse-battery-staple"}'
Response (200):
{
  "token": "pylon_a1b2c3...",
  "user_id": "usr_xyz",
  "expires_at": 1735689600
}
Wrong credentials always return the same shape:
{ "error": { "code": "INVALID_CREDENTIALS", "message": "Email or password is incorrect" } }
The error message intentionally doesn’t say which one is wrong — and the server runs the password hash check even when the email doesn’t exist, so timing analysis can’t enumerate accounts.

From the SDKs

import { configureClient } from "@pylonsync/react";

configureClient({ baseUrl: "https://your-app" });

// Register
const reg = await fetch("/api/auth/password/register", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password, displayName: name }),
}).then(r => r.json());

// Login
const login = await fetch("/api/auth/password/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
}).then(r => r.json());

Why Argon2id?

Pylon uses argon2 with the Argon2id variant — winner of the Password Hashing Competition and the OWASP recommendation. It’s resistant to both side-channel attacks (Argon2i) and GPU-based brute-force (Argon2d) by design. Default parameters:
ParameterValueWhy
Memory cost19 MiBOWASP minimum for interactive logins
Time cost2 iterationsBalances login latency vs attacker cost
Parallelism1Server-side default
Salt16 bytesCSPRNG-generated per password
Each hash is self-describing — the algorithm parameters are stored in the hash string so future Pylon versions can rotate them without breaking existing passwords.

Password reset

Pylon doesn’t ship a /api/auth/password/reset endpoint by design — for password reset, use the magic-code flow:
  1. User clicks “forgot password” → call /api/auth/magic/send
  2. User enters the emailed code → call /api/auth/magic/verify
  3. Now they have a valid session — call your custom setPassword action to update User.passwordHash
This means you maintain one verification path (the email loop) instead of two. A dedicated setPassword action looks like:
import { mutation, v } from "@pylonsync/functions";
import { hashPassword } from "@pylonsync/auth";

export default mutation({
  args: { newPassword: v.string() },
  async handler(ctx, args) {
    if (!ctx.auth.userId) throw new Error("sign in required");
    if (args.newPassword.length < 8) throw new Error("password too short");
    await ctx.db.update("User", ctx.auth.userId, {
      passwordHash: await hashPassword(args.newPassword),
    });
  },
});

Configuring the User entity

Password auth expects a User entity with these fields (auto-created by pylon init):
{
  "name": "User",
  "fields": [
    { "name": "email",        "type": "string",  "unique": true,  "optional": false },
    { "name": "displayName",  "type": "string",  "optional": false },
    { "name": "passwordHash", "type": "string",  "optional": true },
    { "name": "avatarColor",  "type": "string",  "optional": true },
    { "name": "emailVerified","type": "datetime","optional": true },
    { "name": "createdAt",    "type": "datetime","optional": false }
  ]
}
passwordHash is optional because users who signed up via OAuth or magic code never had one. emailVerified is null until they prove control of the email.

Security notes

  • Never deserialize AuthContext from request body. The Rust side intentionally doesn’t derive Deserialize so a client can’t forge is_admin: true. Identity comes from the session lookup, not the wire.
  • /api/auth/session POST is gated — only dev mode or admin token can mint a session for an arbitrary user_id. Your registration/login endpoints are the only public ways to obtain a token.
  • Sessions expire after 30 days by default — see Sessions to change.
  • No rate limit on /login is built in — add the rate_limit plugin if you need brute-force protection at the edge. Cloud applies per-IP rate limiting automatically.

When to use password vs alternatives

Use password when:
  • Email isn’t reliable (offline-first apps, regions with poor SMTP delivery)
  • Compliance requires it
  • Users explicitly prefer it
Otherwise prefer magic codes (no memory load, email is verified by construction) or OAuth (zero credentials your service stores).