Skip to content
loco
v1.0
★ 6.9k Get started

Protect a Route with JWT

Goal: require a valid JWT on a handler, and issue tokens your clients can send back.

Loco ships two axum extractors for JWT-protected routes:

  • auth::JWT — validates the token and gives you the claims. Works without a database.
  • auth::JWTWithUser<T> — validates the token and loads the user record from the database via your model’s Authenticable implementation. Requires the with-db feature.

Both live in loco_rs::controller::extractor::auth and are re-exported through loco_rs::prelude::* whenever the auth feature is on.

If you generated your app with loco new and picked a database-backed starter, with-db and auth are already on by default — see the feature flags reference if you need to check or change that.

1. Enable the auth feature

auth is a default feature (Cargo.toml): it pulls in jsonwebtoken with its pure-Rust rust_crypto backend, so no C toolchain is required even in a minimal build. If you depend on loco-rs with default-features = false, add it back explicitly:

loco-rs = { version = "...", default-features = false, features = ["auth", "with-db"] }

2. Configure the secret and expiration

Add an auth.jwt block to config/development.yaml (and every other environment file):

auth:
jwt:
secret: "{{ get_env(name='JWT_SECRET') }}" # required, must be valid base64
expiration: 604800 # required, seconds (7 days)

Two facts that will save you a confusing error message:

  • The secret must be valid base64. Loco encodes/decodes tokens with EncodingKey::from_base64_secret / DecodingKey::from_base64_secret. A plain, non-base64 string doesn’t fail at config-load time — it fails later, when a token is generated or validated, with an error that doesn’t obviously point at the config. Generate a base64 secret, e.g. openssl rand -base64 64, and inject it via get_env as above rather than hardcoding it.
  • The default signing algorithm is HS512, not HS256. It’s set in code (Algorithm::HS512) and isn’t a YAML key — override it only from Rust, via JWT::algorithm(..) when constructing the signer.

For the full auth.jwt key reference, including location, see the configuration reference. Token location (Bearer / Query / Cookie) is covered in its own guide: Configure where Loco looks for the JWT.

3. Generate a token

Use loco_rs::auth::jwt::JWT (the signer/validator — not to be confused with the extractor of the same name below) to mint a token, typically from a login handler, using the secret and expiration already loaded on AppContext:

use loco_rs::prelude::*;
async fn login(State(ctx): State<AppContext>, /* ... */) -> Result<Response> {
// `user` is whatever you loaded/verified during login (see hash-passwords.md).
let jwt_config = ctx.config.get_jwt_config()?;
let token = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
.generate_token(jwt_config.expiration, user.pid.to_string(), serde_json::Map::new())
.map_err(|e| Error::string(&e.to_string()))?;
format::json(serde_json::json!({ "token": token }))
}

generate_token takes the expiration (seconds), the pid that becomes claims.pid, and an optional serde_json::Map of custom claims that get flattened alongside pid in the token. It returns a jsonwebtoken result, not Loco’s Result, so map the error explicitly as shown.

4. Protect a route: claims only

Add auth::JWT as a handler parameter. Axum runs it as an extractor before your handler body executes; if the token is missing, unparsable, in the wrong location, or expired, the request never reaches your code — Loco returns 401 Unauthorized for you.

use loco_rs::prelude::*;
use loco_rs::controller::extractor::auth;
async fn current(
auth: auth::JWT,
State(_ctx): State<AppContext>,
) -> Result<Response> {
format::json(serde_json::json!({ "pid": auth.claims.pid }))
}

auth.claims is UserClaims { pid, claims, .. } — use auth.claims.pid for the subject, and auth.claims.claims for any custom claims you flattened in at generation time. This extractor needs no database, so it works even in DB-less apps.

5. Protect a route: claims + loaded user

When the handler needs the actual user row (not just the pid), use auth::JWTWithUser<T> where T is your user model. This requires the with-db feature and requires T to implement Authenticable.

use loco_rs::prelude::*;
use loco_rs::controller::extractor::auth;
async fn current(
auth: auth::JWTWithUser<users::Model>,
State(_ctx): State<AppContext>,
) -> Result<Response> {
format::json(&auth.user)
}

Internally, JWTWithUser validates the token exactly like auth::JWT, then calls T::find_by_claims_key(&ctx.db, &claims.pid) to load the user. A database miss becomes 401 Unauthorized; a database error becomes 500 Internal Server Error.

The Authenticable contract

Both JWTWithUser<T> and ApiToken<T> (see the API-key guide) require your user model to implement loco_rs::model::Authenticable:

pub trait Authenticable: Clone {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self>;
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self>;
}

A typical Sea-ORM implementation looks up the row by the relevant column and maps a miss to ModelError::EntityNotFound:

impl Authenticable for super::_entities::users::Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = super::_entities::users::Entity::find()
.filter(super::_entities::users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or(ModelError::EntityNotFound)
}
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
let user = super::_entities::users::Entity::find()
.filter(super::_entities::users::Column::Pid.eq(claims_key))
.one(db)
.await?;
user.ok_or(ModelError::EntityNotFound)
}
}

Note on scope: the framework defines the Authenticable trait and the extractors above, but it does not generate a users model or /api/auth/* controllers for you — loco-gen has no auth/user template. That register/login/verify/reset-password flow, including the Authenticable implementation shown above, ships in the SaaS starter template (loco new → “SaaS app (with DB and user auth)”), which lives outside this repository. Use the pattern above as a starting point if you’re wiring auth into a custom model.

Verify it works

Terminal window
curl --location '127.0.0.1:5150/api/some/protected/route' \
--header 'Authorization: Bearer <TOKEN>'

A missing, malformed, or expired token returns 401 Unauthorized. A valid token reaches your handler with auth.claims (and, for JWTWithUser, auth.user) populated.