Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication

This guide adds users to an Ocre app with ocre g auth: sign-up, login (“remember me”) and logout pages, magic links, password resets and email confirmation by email, account deletion, rate limits, JWTs and API keys for JSON clients, and optionally sessions tracked in D1 (--db-sessions) and “Continue with GitHub / Google” (--oauth). It then shows how to protect pages and endpoints and restrict records to their owner. All of it is generated app code you can read and change; the framework only provides small primitives (password hashing, tokens, JWTs, rate limits, OAuth).

Before you start

  • An app created with ocre new, full-stack or API-only (--api). ocre g auth runs once per app and refuses to run when a User model or a users table exists.
  • The SECRET_KEY_BASE secret, used for session cookies and JWTs; ocre new writes one to .dev.vars (see Sessions, flash and security).
  • For magic links and password resets in production: a mail adapter (MAIL_ADAPTER, see Email). In ocre dev the emails are printed in the console.
  • The outputs below come from an app created with ocre new shop --starter blog, served by ocre dev on the default port 8787.

Generate authentication

ocre g auth

In a full-stack app:

  create  migrations/0002_create_users.sql
  create  migrations/0003_create_auth_tokens.sql
  create  migrations/0004_create_api_keys.sql
  create  src/models/user.rs
  create  src/models/api_key.rs
  create  src/models/auth_token.rs
  create  src/auth_api.rs
  create  src/auth.rs
  create  src/registrations.rs
  create  src/sessions.rs
  create  src/passwords.rs
  create  src/confirmations.rs
  create  templates/auth/signup.html
  create  templates/auth/login.html
  create  templates/auth/account.html
  create  templates/auth/magic_link_new.html
  create  templates/auth/magic_link_show.html
  create  templates/auth/password_new.html
  create  templates/auth/password_edit.html
  create  templates/auth/confirmation_show.html
  update  src/models/mod.rs
  update  src/lib.rs
  update  cloudflare.config.ts

Next:
  ocre migrate
  ocre dev
  open http://localhost:8787/signup

cloudflare.config.ts gets the rate limiter used by every route that checks a password or sends an email (see Rate limiting), after the // ocre:env marker:

// `ocre g auth`: login, sign-up, token and emailed-link routes allow 10 attempts
// a minute per IP address and Cloudflare location (Workers Rate Limiting,
// free plan, no storage used). `period` is 10 or 60 seconds.
AUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "3530462", simple: { limit: 10, period: 60 } }),

The namespace is derived from the app name; two Workers of an account with the same id share counters.

Then apply the migrations and start the app:

ocre migrate
ocre dev

What each file holds, and which ones an API-only app (ocre new --api) gets:

FileFull-stackAPI-onlyContents
migrations/*_create_users.sqlyesyesusers: email (unique, COLLATE NOCASE), password_digest (empty for OAuth-only users), confirmed_at, timestamps
migrations/*_create_auth_tokens.sqlyesSingle-use emailed tokens: user_id, purpose (password_reset, magic_link or email_confirmation), digest (unique), expires_at
migrations/*_create_api_keys.sqlyesyesapi_keys: user_id, name, digest (unique), last_used_at
src/models/user.rsyesyesUser (never serializes password_digest; confirmed(), has_password()), NewUser (email format, password 8 to 128 characters), find, find_by_email, create, create_confirmed, authenticate, update_password, confirm, delete, deletion_confirmed; emails trimmed and lowercased
src/models/auth_token.rsyesissue, peek, consume (single use; valid 15 minutes, a day for email confirmation: valid_minutes)
src/models/api_key.rsyesyescreate (returns the key once), for_user, revoke, authenticate
src/auth.rsyesCurrentUser, ConfirmedUser, OptionalUser, sign_in, sign_out, origin, SESSION_SECONDS, OAUTH_PROVIDERS
src/registrations.rsyesGET/POST /signup, GET /account (an example protected page), POST /account/delete
src/sessions.rsyesGET/POST /login, POST /logout, GET/POST /magic_link, GET/POST /magic_link/{token}
src/passwords.rsyesGET /passwords/new, POST /passwords, GET/POST /passwords/{token}
src/confirmations.rsyessend_confirmation; POST /confirmations (a new link), GET/POST /confirmations/{token}
templates/auth/*.htmlyesThe pages
src/auth_api.rsyesyesBearerUser, throttle, TOKEN_LOCATIONS; POST /api/auth/signup, POST /api/auth/token, GET/DELETE /api/auth/me, GET/POST /api/auth/keys, DELETE /api/auth/keys/{id}
cloudflare.config.tsyesyesThe AUTH_RATE_LIMITER binding

Two options add more, in full-stack apps only:

OptionAdds
--db-sessionsmigrations/*_create_user_sessions.sql, src/models/user_session.rs, src/user_sessions.rs and templates/auth/user_sessions.html; src/auth.rs stores sessions in D1 (see Sessions tracked in D1)
--oauth github,googlemigrations/*_create_identities.sql, src/models/identity.rs, src/oauth.rs, the providers’ buttons on the login page, commented secrets in .dev.vars (see Continue with GitHub or Google)

In an API-only app the output is:

  create  migrations/0001_create_users.sql
  create  migrations/0002_create_api_keys.sql
  create  src/models/mod.rs
  create  src/models/user.rs
  create  src/models/api_key.rs
  create  src/auth_api.rs
  update  src/lib.rs
  update  cloudflare.config.ts

Next:
  ocre migrate
  ocre dev
  curl -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' -d '{"email":"ada@example.com","password":"correct horse"}'

Running it a second time fails:

error: this app already has a User model or a users table
hint: `ocre g auth` creates both and runs once per app; to start over, remove src/models/user.rs and the create_users migration

Run ocre routes to see every route the generator added.

Sign-up, login and logout

The HTML flows are ordinary forms; the examples use curl with a cookie jar (-c saves cookies, -b sends them) to show what a browser gets.

A visitor who opens a protected page is sent to /login, and the page is remembered:

curl -si -c jar.txt -b jar.txt http://localhost:8787/account
HTTP/1.1 303 See Other
Content-Length: 0
Location: /login
Set-Cookie: _ocre_session=OomyKZrKiIV5EQpPSEG9Ce4hmLjenLFK8MCStDD4c0RVxh4hgPGVu4hMn%2FKFAmNCCW0v0WmFNC3IfOX6ch2l1qvsfg+X%2FjJmaAexvIoNgANOYmXUhTSzyuvNoO2FZK+D0+8D2Q%3D%3D; HttpOnly; SameSite=Lax; Path=/
...

Signing up creates the user, signs them in and returns to that page:

curl -si -c jar.txt -b jar.txt http://localhost:8787/signup -d 'email=ada@example.com&password=correct horse'
HTTP/1.1 303 See Other
Content-Length: 0
Location: /account
Set-Cookie: _ocre_session=HZlSU2bCVeNT7CWPgmbw2p6DaYDY9S%2FOB3S8Wu%2FiKTdXxippgjk2zgYIuqY6JvzqswPId3bRHmAF7SYVfWT0MZI0Hrv6legGNBfwImUsSW06alc2QQRbh5tC6livVjw%3D; HttpOnly; SameSite=Lax; Path=/
...

GET /account now shows the flash message, the user, and a reminder to confirm the address (see Email confirmation):

<h1>Your account</h1>
<p class="notice">Welcome! Your account is ready. Check your email to confirm your address.</p>
...
  <dt>Email</dt><dd>ada@example.com</dd>
  <dt>Member since</dt><dd>2026-09-29 04:32:55</dd>
...
  <p class="alert">Your email address is not confirmed yet.</p>
...

Invalid input re-renders the form with status 422 and every message at once (emails are compared without case):

curl -s http://localhost:8787/signup -d 'email=ADA@example.com&password=short'
...
  <ul class="errors">
    <li>Password is too short (minimum is 8 characters)</li><li>Email has already been taken</li>
  </ul>
...

Log out (POST, so other sites cannot log users out with a link), then log in:

curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/logout -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=ada@example.com&password=wrong' -o /dev/null -w '%{http_code}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=Ada@Example.com&password=correct horse' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/
422
303 http://localhost:8787/

The failed login re-renders the form with “Invalid email or password.” whether the email or the password was wrong. After a successful login the user goes back to the page that asked for it (only a path of this app: ocre::security::url_from refuses anything else), or /.

Remember me and session expiry

Every sign-in lasts two weeks (SESSION_SECONDS in src/auth.rs). The expiry time is stored inside the encrypted session cookie and checked on every request, so a copied cookie stops working after it too. Without “Remember me” the cookie ends with the browser session; with the box ticked (remember_me=1) it is persistent:

curl -si -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=ada@example.com&password=correct horse&remember_me=1' | grep -i set-cookie
Set-Cookie: _ocre_session=...; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600

sign_in calls session.remember_for(SESSION_SECONDS) or session.expire_in(SESSION_SECONDS); use the same methods of ocre::Session for other expiring state (see Sessions, flash and security).

GET /magic_link shows a form asking for an email; POST /magic_link emails a sign-in link valid 15 minutes. The answer is the same redirect to /login (“If an account exists for that email, a sign-in link is on its way.”) whether or not the address has an account:

curl -s http://localhost:8787/magic_link -d 'email=ada@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s http://localhost:8787/magic_link -d 'email=nobody@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/login
303 http://localhost:8787/login

With MAIL_ADAPTER=log (what ocre new puts in .dev.vars) the email is printed in the ocre dev output:

[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Your sign-in link

Open this link within 15 minutes to sign in:

http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E

If you did not ask for it, ignore this email.

[ocre mail] HTML version:
<p><a href="http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E">Sign in</a> (valid 15 minutes).</p><p>If you did not ask for it, ignore this email.</p>
[ocre mail] end

Opening the link (GET) shows a page with a “Sign in” button; the button POSTs to the same URL, which uses the token up and signs in. Mail scanners that follow links therefore do not consume it. A second use fails:

curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt -X POST http://localhost:8787/magic_link/e_ZL4u_uKZl0rYbpJe82lyqwQUPnFZTCOzUC59bGE2E -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/
303 http://localhost:8787/magic_link

The second redirect carries the alert “That sign-in link is invalid or has expired.” Requesting a new link cancels the previous one.

Password reset

GET /passwords/new asks for an email; POST /passwords emails a reset link (same answer whether or not the account exists):

curl -s http://localhost:8787/passwords -d 'email=ada@example.com' -o /dev/null -w '%{http_code} %{redirect_url}\n'
303 http://localhost:8787/login
[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Reset your password

Open this link within 15 minutes to choose a new password:

http://localhost:8787/passwords/B8iQKwfm0L86-KOJuVtrep857CdZO0yfERAlZ3ntLFs
...

GET /passwords/{token} shows the form (an invalid or expired token redirects to /passwords/new with an alert). POST /passwords/{token} checks password and password_confirmation, then uses the token up, changes the password and redirects to /login:

T=B8iQKwfm0L86-KOJuVtrep857CdZO0yfERAlZ3ntLFs
curl -s http://localhost:8787/passwords/$T -d 'password=new password&password_confirmation=typo' -w '\n%{http_code}\n'
curl -s http://localhost:8787/passwords/$T -d 'password=new password 2&password_confirmation=new password 2' -o /dev/null -w '%{http_code} %{redirect_url}\n'
...
  <ul class="errors">
    <li>Password confirmation doesn&#39;t match Password</li>
  </ul>
...
422
303 http://localhost:8787/login

A validation error does not use the token up, so the user can correct the form. The reset also confirms the email address (the link was opened from the mailbox). A password change does not sign out other browsers with cookie sessions; with --db-sessions, “Sign out everywhere else” does (see Sessions tracked in D1).

Email confirmation

Sign-up emails a confirmation link valid 24 hours (src/confirmations.rs); the account works before it is confirmed:

[ocre mail] not sent (MAIL_ADAPTER = "log")
From: shop <noreply@example.com>
To: ada@example.com
Subject: Confirm your email address

Confirm your email address by opening this link within 24 hours:

http://localhost:8787/confirmations/<token>
...

Like magic links, GET /confirmations/{token} shows a button and POST uses the token up, sets users.confirmed_at and redirects to /account with “Thanks, your email address is confirmed.” An invalid, expired or used link gets “That confirmation link is invalid or has expired.” The account page of an unconfirmed user has a “Send a new confirmation link” button (POST /confirmations). Signing in with a magic link, resetting the password or signing in with an OAuth provider whose email is verified also confirms the address.

Pages that need a confirmed address take ConfirmedUser instead of CurrentUser (next section).

Protect HTML pages

Take one of the extractors from src/auth.rs as a handler argument:

ExtractorGivesVisitor without a session
CurrentUser(user): CurrentUserUserRedirected to /login with the alert “Please log in to continue.”; for GET requests the page is remembered and the user comes back to it after logging in
ConfirmedUser(user): ConfirmedUserUser with a confirmed emailLike CurrentUser; an unconfirmed user is redirected to /account with “Please confirm your email address first.”
OptionalUser(user): OptionalUserOption<User>None; the page renders

They read user_id from the session and load the user with one D1 query (with --db-sessions, one query joining the session and the user). This module adds a dashboard for signed-in users and a greeting for everyone; register it with mod dashboard; under // ocre:modules and .merge(dashboard::routes()) under // ocre:routes in src/lib.rs:

// src/dashboard.rs
use askama::Template;
use axum::{Router, extract::State, response::Html, routing::get};
use ocre::{Ctx, Flash, Result, render};

use crate::{
    auth::{CurrentUser, OptionalUser},
    models::{
        api_key::{self, ApiKey},
        user::User,
    },
};

pub fn routes() -> Router<Ctx> {
    Router::new().route("/dashboard", get(show)).route("/hello", get(hello))
}

#[derive(Template)]
#[template(
    source = r#"{% if let Some(notice) = flash.notice() %}<p class="notice">{{ notice }}</p>{% endif %}
<h1>Dashboard of {{ user.email }}</h1>
<ul>{% for key in keys %}<li>{{ key.name }}</li>{% endfor %}</ul>
<form method="post" action="/logout"><button>Log out</button></form>"#,
    ext = "html"
)]
struct DashboardView {
    flash: Flash,
    user: User,
    keys: Vec<ApiKey>,
}

/// Visitors are redirected to /login, then back here after logging in.
async fn show(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, flash: Flash) -> Result<Html<String>> {
    let keys = api_key::for_user(&ctx, user.id).await?;
    render(&DashboardView { flash, user, keys })
}

/// Works for everyone; greets signed-in users by email.
async fn hello(OptionalUser(user): OptionalUser) -> String {
    match user {
        Some(user) => format!("Hello, {}", user.email),
        None => "Hello, visitor".to_owned(),
    }
}

Run against ocre dev, signed out then signed in:

curl -s -c jar.txt -b jar.txt http://localhost:8787/dashboard -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/login -d 'email=Ada@Example.com&password=correct horse' -o /dev/null -w '%{http_code} %{redirect_url}\n'
curl -s -c jar.txt -b jar.txt http://localhost:8787/dashboard
curl -s -b jar.txt http://localhost:8787/hello; echo
curl -s http://localhost:8787/hello; echo
303 http://localhost:8787/login
303 http://localhost:8787/dashboard
<p class="notice">Signed in.</p>
<h1>Dashboard of ada@example.com</h1>
<ul></ul>
<form method="post" action="/logout"><button>Log out</button></form>
Hello, ada@example.com
Hello, visitor

To sign a user in from your own code (after an invitation, say), call auth::sign_in(&session, &user)?: it empties the session first, stores only the user id, and returns the path to redirect to. Sign out with auth::sign_out(&session)?. Never put more than the id in the session.

JSON clients: JWTs and API keys

JSON clients cannot rely on the session cookie; they send Authorization: Bearer <token>, where the token is a JWT or an API key. These routes come from src/auth_api.rs:

RouteBodyAnswer
POST /api/auth/signup{"email", "password"}201, the user
POST /api/auth/token{"email", "password"}{"token", "token_type": "Bearer", "expires_in": 3600}
GET /api/auth/meThe user of the token
GET /api/auth/keysThe user’s API keys (never the secrets)
POST /api/auth/keys{"name"}201, {"key", "api_key"}: key is shown this once
DELETE /api/auth/keys/{id}204, or 404 when the user has no such key

Sign up and get a JWT:

curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
  -d '{"email":"grace@example.com","password":"correct horse"}'
curl -s -X POST http://localhost:8787/api/auth/token -H 'content-type: application/json' \
  -d '{"email":"grace@example.com","password":"correct horse"}'
{"id":2,"email":"grace@example.com","created_at":"2026-09-29 04:35:02","updated_at":"2026-09-29 04:35:02"}
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNzkwNjU2NTAyLCJleHAiOjE3OTA2NjAxMDJ9.tucAZ1VrmMVeU7i0etJVIr568y13Iit2xJwJdsXs4Ag","token_type":"Bearer","expires_in":3600}

Wrong credentials, or a missing or invalid token, get a JSON 401 with WWW-Authenticate: Bearer:

curl -si -X POST http://localhost:8787/api/auth/token -H 'content-type: application/json' \
  -d '{"email":"grace@example.com","password":"nope"}'
HTTP/1.1 401 Unauthorized
Transfer-Encoding: chunked
Content-Type: application/json
WWW-Authenticate: Bearer
...

{"error":{"message":"Unauthorized","status":401}}

Use the JWT, then create a long-lived API key with it:

TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl -s http://localhost:8787/api/auth/me -H "Authorization: Bearer $TOKEN"
curl -s -X POST http://localhost:8787/api/auth/keys -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"name":"CI deploys"}'
curl -s http://localhost:8787/api/auth/keys -H "Authorization: Bearer $TOKEN"
{"id":2,"email":"grace@example.com","created_at":"2026-09-29 04:35:02","updated_at":"2026-09-29 04:35:02"}
{"key":"5DNdPEYb0h2uCpRSUS4jFm-i05HI1DthdAgyL5tL_aY","api_key":{"id":1,"user_id":2,"name":"CI deploys","last_used_at":null,"created_at":"2026-09-29 04:35:11"}}
[{"id":1,"user_id":2,"name":"CI deploys","last_used_at":null,"created_at":"2026-09-29 04:35:11"}]

The key works like a JWT: Authorization: Bearer 5DNd.... It is used in the next sections as $KEY, and revoked at the end of Records that belong to a user.

JWTs versus API keys:

JWTAPI key
Lifetime1 hour (TOKEN_TTL_SECONDS in src/auth_api.rs)Until revoked
RevocableNo, it expiresYes, DELETE /api/auth/keys/{id}
Check costOne HMAC-SHA256, no D1 read to verify (plus the user lookup)One D1 read by digest, plus a last_used_at write at most once an hour
Use forApps that log in with a passwordScripts, CI, integrations

Bearer requests without an Origin or Sec-Fetch-Site header (curl, servers, mobile apps) pass the CSRF check; a separate browser frontend on another origin needs ALLOWED_ORIGINS (see Sessions, flash and security).

Protect JSON endpoints

Take BearerUser(user): BearerUser from src/auth_api.rs. It accepts a JWT (anything with dots) or an API key, loads the user, and otherwise answers the JSON 401 above; a deleted user’s tokens stop working. Put it before Json(..) in the arguments (axum runs the body extractor last).

This endpoint returns one of the caller’s API keys. The user_id condition in the query is the ownership check: another user’s key is “not found”, exactly like a missing one, so the answer reveals nothing. Register it like the dashboard module:

// src/my_keys_api.rs
use axum::{
    Router,
    extract::{Path, State},
    routing::get,
};
use ocre::{ApiResult, Ctx, Json, OptionExt, Result, params};

use crate::{auth_api::BearerUser, models::api_key::ApiKey};

pub fn routes() -> Router<Ctx> {
    Router::new().route("/api/my/keys/{id}", get(show))
}

/// One of the user's API keys. The `user_id` condition is the ownership
/// check: another user's key is "not found", exactly like a missing one.
pub async fn find_owned(ctx: &Ctx, user_id: i64, id: i64) -> Result<Option<ApiKey>> {
    ctx.db()?
        .first(
            "SELECT id, user_id, name, last_used_at, created_at FROM api_keys WHERE id = ?1 AND user_id = ?2",
            params![id, user_id],
        )
        .await
}

/// 401 JSON without a valid `Authorization: Bearer <JWT or API key>`.
async fn show(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Path(id): Path<i64>) -> ApiResult<Json<ApiKey>> {
    Ok(Json(find_owned(&ctx, user.id, id).await?.or_404()?))
}

With Grace’s API key, then with a JWT of Ada (user 1, from POST /api/auth/token with her email and password):

KEY=5DNdPEYb0h2uCpRSUS4jFm-i05HI1DthdAgyL5tL_aY
ADA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzkwNjU2NTE3LCJleHAiOjE3OTA2NjAxMTd9.j6v-x42xurVULPJno1bh4lxP038jiRKVAFqac45QZoY
curl -s http://localhost:8787/api/my/keys/1 -H "Authorization: Bearer $KEY"
curl -s http://localhost:8787/api/my/keys/1 -H "Authorization: Bearer $ADA" -w ' %{http_code}\n'
{"id":1,"user_id":2,"name":"CI deploys","last_used_at":"2026-09-29 04:35:17","created_at":"2026-09-29 04:35:11"}
{"error":{"message":"Not found","status":404}} 404

last_used_at was set by the first request made with the key.

Records that belong to a user

Give a table a user_id column with references, then filter every query by the signed-in user’s id.

ocre g model Note title:string user:references
  create  migrations/0005_create_notes.sql
  create  src/models/note.rs
  update  src/models/user.rs
  update  src/models/mod.rs

Next:
  ocre migrate
  cargo check --target wasm32-unknown-unknown

The migration has user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE (a user’s notes are deleted with the user) and an index on user_id. src/models/note.rs gets note.user(&ctx), and src/models/user.rs gets the list query, already filtered by owner:

impl User {
    // ocre:associations
    /// Notes of this user, newest first.
    pub async fn notes(&self, ctx: &Ctx, page: ocre::Page) -> Result<Vec<crate::models::note::Note>> {
        ctx.db()?
            .all(
                "SELECT * FROM notes WHERE user_id = ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3",
                params![self.id, page.limit, page.offset],
            )
            .await
    }
}

A JSON API over the notes then takes the owner from the token, never from the request body, and answers 404 for other users’ notes:

// src/notes_api.rs
use axum::{
    Router,
    extract::{Path, State},
    routing::get,
};
use ocre::{ApiResult, Created, Ctx, Error, Json, Page};
use serde::Deserialize;

use crate::{
    auth_api::BearerUser,
    models::note::{self, NewNote, Note},
};

pub fn routes() -> Router<Ctx> {
    Router::new().route("/api/notes", get(index).post(create)).route("/api/notes/{id}", get(show))
}

#[derive(Deserialize)]
struct NoteInput {
    title: String,
}

async fn index(State(ctx): State<Ctx>, BearerUser(user): BearerUser, page: Page) -> ApiResult<Json<Vec<Note>>> {
    Ok(Json(user.notes(&ctx, page).await?))
}

async fn create(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Json(input): Json<NoteInput>) -> ApiResult<Created<Note>> {
    // The owner comes from the token, never from the request body.
    Ok(Created(note::create(&ctx, NewNote { title: input.title, user_id: user.id }).await?))
}

async fn show(State(ctx): State<Ctx>, BearerUser(user): BearerUser, Path(id): Path<i64>) -> ApiResult<Json<Note>> {
    match note::find(&ctx, id).await? {
        Some(note) if note.user_id == user.id => Ok(Json(note)),
        _ => Err(Error::NotFound.into()),
    }
}

Grace creates a note; Ada cannot see it:

curl -s -X POST http://localhost:8787/api/notes -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{"title":"Buy flour"}'
curl -s http://localhost:8787/api/notes/1 -H "Authorization: Bearer $ADA" -w ' %{http_code}\n'
curl -s http://localhost:8787/api/notes -H "Authorization: Bearer $ADA"; echo
{"id":1,"title":"Buy flour","user_id":2,"created_at":"2026-09-29 04:35:17","updated_at":"2026-09-29 04:35:17"}
{"error":{"message":"Not found","status":404}} 404
[]

Finally, Grace revokes her API key; it stops working at once:

curl -s -X DELETE http://localhost:8787/api/auth/keys/1 -H "Authorization: Bearer $KEY" -w '%{http_code}\n'
curl -s http://localhost:8787/api/auth/me -H "Authorization: Bearer $KEY" -w ' %{http_code}\n'
204
{"error":{"message":"Unauthorized","status":401}} 401

Rules for owned records:

  • Filter in SQL (WHERE id = ?1 AND user_id = ?2) for updates and deletes, so one statement both checks and acts; api_key::revoke in src/models/api_key.rs does this.
  • Answer Error::NotFound for other users’ records, so ids of other users’ data are not confirmed. Use Error::Forbidden (403) when the user may know the record exists but may not change it (a shared document that only its owner edits).
  • The HTML scaffold pages of ocre g scaffold do not check ownership; add CurrentUser and the user_id filter to each handler that should be private.

Add a column to users

Add a migration, apply it, then update the model by hand (the generator does not edit user.rs):

ocre g migration add_name_to_users name:string?
ocre migrate
  create  migrations/0006_add_name_to_users.sql

Next:
  ocre migrate
  update the model in src/models/ to match the new columns
-- Migration: add_name_to_users
-- Applied once, in file-name order. Never edit a migration after it has been applied.
ALTER TABLE users ADD COLUMN name TEXT;

In src/models/user.rs: add pub name: Option<String>, to User (it is serialized in JSON answers; keep #[serde(skip_serializing)] for anything secret), pub name: String, to NewUser (missing in a form or JSON body means empty, thanks to #[serde(default)]), a rule in NewUser::validate (v.max_length("name", &self.name, 100);), and the column in the INSERT of create:

    let name = Some(new.name.trim()).filter(|name| !name.is_empty());
    db.first(
        "INSERT INTO users (email, password_digest, name) VALUES (?1, ?2, ?3) RETURNING *",
        params![email, password_digest, name],
    )

For the HTML sign-up, add <label>Name <input name="name"></label> to templates/auth/signup.html. The JSON sign-up takes it right away:

curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
  -d '{"email":"linus@example.com","password":"correct horse","name":"Linus"}'
curl -s -X POST http://localhost:8787/api/auth/signup -H 'content-type: application/json' \
  -d '{"email":"bad","password":"short"}'
{"id":3,"email":"linus@example.com","name":"Linus","created_at":"2026-09-29 04:42:46","updated_at":"2026-09-29 04:42:46"}
{"error":{"fields":{"email":["is invalid"],"password":["is too short (minimum is 8 characters)"]},"message":"Validation failed","status":422}}

Account deletion

POST /account/delete with confirmation (the password, or the email address for users without one) deletes the user and signs out; tokens, API keys, sessions and identities go with it (ON DELETE CASCADE). JSON clients send DELETE /api/auth/me with {"password": "..."}: 204, or 403 for a wrong password.

Rate limiting

Every route that checks a password or sends an email calls throttle(&ctx, &headers, "login") (in src/auth_api.rs), which counts one attempt per action and client IP address (ocre::remote_ip) with ocre::security::rate_limit against the AUTH_RATE_LIMITER Workers Rate Limiting binding. Over 10 a minute the answer is 429 Too Many Requests (JSON: {"error":{"status":429,"message":"Too many requests. Try again later."}}). The binding is on the free plan, uses no D1 or KV operation, and ocre dev simulates it; counters are per Cloudflare location and approximate. Change limit and period (10 or 60 seconds) in cloudflare.config.ts.

Sessions tracked in D1

With ocre g auth --db-sessions, each sign-in is a row of user_sessions (IP address, browser, last activity, expiry) and the cookie holds a random token whose SHA-256 digest finds the row (Rails 8’s Session model). /account/sessions lists the signed-in devices, “Sign out” ends one, “Sign out everywhere else” ends all others; deleting a row signs that device out on its next request. Cost: every signed-in request reads the session and its user (one query, 2 D1 rows); last_seen_at is written at most once an hour.

Continue with GitHub or Google

With ocre g auth --oauth github,google, the login page gets one button per provider. src/oauth.rs runs the OAuth 2.0 code flow with PKCE through ocre::oauth: POST /auth/{provider} stores a random state and PKCE verifier in the session and redirects to the provider; GET /auth/{provider}/callback checks the state, trades the code for a token, reads the profile, and signs in the user linked to that account (identities table), else the user with the same verified email (then linked), else a new confirmed user without a password. Register an OAuth app with the callback URL https://<your host>/auth/<provider>/callback (and one for http://localhost:8787), put GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (or GOOGLE_...) in .dev.vars, and upload them with ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars (a git-ignored file of NAME=value lines). A sign-in costs 2 or 3 subrequests of the 50 a free-plan request may make.

Framework primitives

The generated code is built on these ocre items; use them for your own flows (invitations, email confirmation, one-time codes):

ItemWhat it doesCost
ocre::password::hash(&password).await?PBKDF2-HMAC-SHA256 digest, pbkdf2_sha256$100000$<salt>$<hash> (16-byte random salt)About 5.5 ms of CPU (WebCrypto, outside WebAssembly): one per request at most
ocre::password::verify(&password, &digest).await?bool, constant-time comparisonSame as hash
ocre::password::iterations(&digest)The iteration count stored in a digestNone
ocre::token::generate()256 random bits, URL-safe base64 (43 characters)None
ocre::token::digest(&token)SHA-256 of a token, to store and look up instead of the tokenNone
ocre::token::constant_time_eq(a, b)Compares secrets without timing leaksNone
ocre::jwt::encode(&ctx, &Claims::new(user.id.to_string(), ttl))?HS256 JWT with sub, iat, exp; key derived from SECRET_KEY_BASEOne HMAC
ocre::jwt::decode(&ctx, &token)?Claims, or Error::Unauthorized when expired, badly signed, or not HS256 (including alg: none)One HMAC
ocre::now()Unix seconds; SystemTime::now() panics on wasm32-unknown-unknownNone
Error::Unauthorized401; JSON answers add WWW-Authenticate: Bearer
Error::Forbidden403, for “signed in but not allowed”

Store only token::digest(&token) for secrets you email or show once, and look rows up by digest, as src/models/auth_token.rs and src/models/api_key.rs do. The full signatures are in the API index and the rustdoc of ocre::password, ocre::token and ocre::jwt.

Before going public

  • Mail. Magic links and password resets call ocre::mail::send. Without MAIL_ADAPTER in production, POST /magic_link and POST /passwords answer 500 and the log says cannot send email: MAIL_ADAPTER is not set. Fix: .... For sign-up and reset mail to any address on the free plan, use Resend (100 emails a day, 3,000 a month, September 2026); see Email.
  • Rate limiting. Keep the AUTH_RATE_LIMITER entry in cloudflare.config.ts (see Rate limiting): each password check costs about 5.5 ms of CPU, and every emailed link costs a send from your mail quota. Its counters are per Cloudflare location and approximate; with a custom domain, a Cloudflare WAF rate limiting rule on /login, /signup, /magic_link, /passwords and /api/auth/* adds a limit before the Worker runs (see Deployment).
  • OAuth. With --oauth, put the provider’s ..._CLIENT_ID and ..._CLIENT_SECRET in .prod.vars and upload them with ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars, and register the production callback URL with the provider.
  • Secret. ocre deploy creates SECRET_KEY_BASE on the first deploy; never commit .dev.vars.

Security choices

In short (the reasoning is in Security model):

  • Passwords: PBKDF2-HMAC-SHA256 with 100,000 iterations through WebCrypto, the most Workers accept (OWASP recommends 600,000; bcrypt or argon2 in WebAssembly would not fit in 10 ms of CPU). A login with an unknown email hashes too, so response times do not reveal accounts.
  • Sessions: the encrypted cookie holds only user_id (with --db-sessions, a random session token whose digest finds the D1 row) and its expiry; login empties the session first, logout clears it. CurrentUser only returns to local paths (no open redirect).
  • Emailed tokens: 256 random bits, stored as SHA-256 digests, valid 15 minutes (email confirmation links: a day), used up by one DELETE ... RETURNING (single use even under concurrent requests); a new request cancels the previous link; links open a page whose button POSTs. Links use the request’s origin.
  • JWTs: HS256 only, key derived from SECRET_KEY_BASE with a fixed label (so it differs from the cookie key); no separate JWT_SECRET. Rotating SECRET_KEY_BASE signs everyone out of sessions and tokens at once, unless the old value is kept in SECRET_KEY_BASE_PREVIOUS for a while (see Rotating SECRET_KEY_BASE without signing everyone out).
  • API keys: 256 random bits shown once, stored as SHA-256 digests, revocable; last_used_at written at most once an hour to save D1 writes.

What is not included

  • Revoking cookie sessions before they expire, unless you use --db-sessions.
  • Roles and permissions: Error::Forbidden is there for your own checks.
  • Revoking a JWT before it expires (use API keys for long-lived access).

See also