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
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:
$ cargo loco routes...[POST] /api/auth/register[GET] /api/auth/verify/{token}[POST] /api/auth/login[POST] /api/auth/forgot[POST] /api/auth/reset[GET] /api/auth/current[POST] /api/auth/magic-link[GET] /api/auth/magic-link/{token}[POST] /api/auth/resend-verification-mail...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
cargo loco start4. 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
$ 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
$ 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
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:text --apiOpen 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
- 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.
Next
- 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.