Skip to content
★ GitHubGet started

Build a Small Authenticated App

Every Loco app generated with a database ships with a complete authentication suite: registration, login, email verification, password reset, magic links, and a JWT-protected “current user” endpoint — no extra generator, no starter template to hunt for. This lesson generates one, exercises the built-in auth flow end to end, then protects a resource of your own with the same JWT extractor the built-in endpoints use.

You should already be comfortable with the basics from Your First App.

Terminal window
loco new --name saas_app --db sqlite --bg async --assets serverside
cd saas_app

This is the same combination of flags Loco’s own examples/demo app in the loco-rs repository is generated with — server-rendered assets, SQLite, and in-process async workers. Choosing a database is what turns on authentication: auth and mailer scaffolding are both included automatically whenever --db is sqlite or postgres, regardless of which starter you picked interactively — there’s no separate “SaaS” flag to remember.

Confirm the auth routes are already there, with nothing else generated yet:

routes prints a tree of path segments, with each endpoint’s method and its full path on the right:

Terminal window
$ cargo loco routes
/_health GET /_health
/_ping GET /_ping
/_readiness GET /_readiness
/api
└─ /auth
├─ /current GET /api/auth/current
├─ /forgot POST /api/auth/forgot
├─ /login POST /api/auth/login
├─ /magic-link
├─ POST /api/auth/magic-link
└─ /{token} GET /api/auth/magic-link/{token}
├─ /register POST /api/auth/register
├─ /resend-verification-mail POST /api/auth/resend-verification-mail
├─ /reset POST /api/auth/reset
└─ /verify/{token} GET /api/auth/verify/{token}

The three _-prefixed routes are Loco’s built-in monitoring endpoints, added by AppRoutes::with_default_routes(); everything under /api/auth came with the database.

Registering a user sends a welcome email through the configured mailer. config/development.yaml enables SMTP against localhost:1025 by default, which means registration will fail with a 500 unless you either run a local SMTP catcher there, or tell the mailer to stub outgoing mail instead of sending it. For this lesson, stub it — open config/development.yaml and add stub: true under mailer:

mailer:
stub: true
smtp:
enable: true
host: localhost
# ...
The generated config/test.yaml already sets mailer.stub: true — that's why your app's tests never need a live mailbox. You're applying the same setting to development.yaml so cargo loco start behaves the same way.
Terminal window
cargo loco start
Terminal window
$ curl --location 'localhost:5150/api/auth/register' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Loco user",
"email": "user@loco.rs",
"password": "12341234"
}'
{}

An empty {} on success is intentional: the endpoint always answers the same way whether or not the email was already registered, so no request can be used to probe your user list.

Terminal window
$ curl --location 'localhost:5150/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user@loco.rs",
"password": "12341234"
}'
{
"token": "eyJhbGciOiJIUzUxMiJ9...",
"pid": "2b20f998-b11e-4aeb-96d7-beca7671abda",
"name": "Loco user",
"is_verified": false
}

is_verified is false because you haven’t clicked the (stubbed, unsent) verification email — that’s fine, login doesn’t require a verified email, only a matching password. Save the token; every authenticated request below uses it as a bearer token.

Terminal window
$ curl --location 'localhost:5150/api/auth/current' \
--header 'Authorization: Bearer TOKEN'
{
"pid": "2b20f998-b11e-4aeb-96d7-beca7671abda",
"name": "Loco user",
"email": "user@loco.rs"
}

Under the hood, current is nothing special — it’s a normal handler that takes auth::JWT as its first argument:

async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
}

If the Authorization header is missing, malformed, or carries an expired/invalid token, axum never reaches your handler body — the auth::JWT extractor itself rejects the request with 401 Unauthorized. Try it without the header to see that happen.

The pattern above works for any handler, not just the built-in ones. Generate a notes scaffold:

Terminal window
$ cargo loco generate scaffold notes title:string content:text

Open src/controllers/notes.rs and change the add handler’s signature to also require auth::JWT:

pub async fn add(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Response> {
// we only need to know the request carries a valid, known user
let _current_user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
let mut item = ActiveModel { ..Default::default() };
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
format::json(item)
}

auth::JWT is already in scope through loco_rs::prelude::*, which every generated controller imports. Restart the app and confirm the two behaviors:

Terminal window
# no token: rejected before your handler even runs
$ curl -X POST -H "Content-Type: application/json" \
-d '{"title":"secret","content":"shh"}' localhost:5150/api/notes
# 401 Unauthorized
# with token: goes through
$ curl -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"title":"secret","content":"shh"}' localhost:5150/api/notes
{"id":1,"created_at":"...","updated_at":"...","title":"secret","content":"shh"}

list, get_one, update, and remove on notes are still open to anyone — add auth: auth::JWT to their signatures the same way if you want the whole resource locked down.

What’s actually enforced, and what isn’t

Section titled “What’s actually enforced, and what isn’t”
  • The JWT secret and expiration live in config/development.yaml under auth.jwt. Every environment (development, test, production) gets its own generated secret — never share one across environments. See the Configuration reference for every key under auth:.
  • Tokens are signed HS512 by default, and the configured secret must be valid base64 — this is handled for you in the generated config, but matters if you ever hand-roll one.
  • auth::JWT only checks that the token is valid and unexpired; it does not check is_verified. If your app needs “must have verified their email” as a business rule, check user.email_verified_at.is_some() yourself inside the handler, the same way you looked up the user by pid above.
  • Protect a Route with JWT — the full endpoint-by-endpoint reference: forgot/reset password, email verification, magic links, and API-key auth as an alternative to JWTs.
  • Configuration reference — every auth: and mailer: YAML key.
  • The Tour — if you haven’t yet, see models, workers, and tasks covered end to end.
  • Add a model — keep building out notes (or your own resource) with relations and validation.