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.
1. Generate the app
Section titled “1. Generate the app”loco new --name saas_app --db sqlite --bg async --assets serversidecd saas_appThis 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:
$ 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.
2. Skip the SMTP dependency
Section titled “2. Skip the SMTP dependency”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 # ...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.
3. Start the app
Section titled “3. Start the app”cargo loco start4. Register a user
Section titled “4. Register a user”$ 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.
5. Log in
Section titled “5. Log in”$ 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.
6. Call the built-in protected endpoint
Section titled “6. Call the built-in protected endpoint”$ 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.
7. Protect a resource of your own
Section titled “7. Protect a resource of your own”The pattern above works for any handler, not just the built-in ones. Generate a notes scaffold:
$ cargo loco generate scaffold notes title:string content:textOpen 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:
# 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.yamlunderauth.jwt. Every environment (development,test,production) gets its own generated secret — never share one across environments. See the Configuration reference for every key underauth:. - 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::JWTonly checks that the token is valid and unexpired; it does not checkis_verified. If your app needs “must have verified their email” as a business rule, checkuser.email_verified_at.is_some()yourself inside the handler, the same way you looked up the user bypidabove.
- 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:andmailer: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.