navree

Authentication

JWT access tokens, rotating refresh tokens with reuse detection, Passport-style strategies and the CurrentUser extractor.

On this page

Navree authenticates API requests the way a NestJS app does. A client signs in with an email address and a password and gets two tokens: a short-lived JWT access token, which it sends as Authorization: Bearer with every request, and an opaque refresh token, which it trades for a new pair when the access token expires. navree-auth provides the routes, the token handling, Passport-style strategies and a CurrentUser extractor. Routes stay denied unless they say who may call them.

How it works

  • POST /api/auth/login checks the password and returns an access token (an HS256 JWT, valid 15 minutes) and a refresh token (an opaque string, valid 30 days).
  • The client sends Authorization: Bearer <access token> with every request. The built-in JWT strategy verifies the signature and the expiry without touching the database.
  • When the access token expires, the client posts its refresh token to POST /api/auth/refresh and gets a new pair. The old refresh token stops working; presenting it again revokes the whole session.
  • POST /api/auth/logout revokes the session's refresh tokens.

A browser app shouldn't hold the tokens in JavaScript at all. The BFF pattern below keeps them in httpOnly cookies on your web server, as the reference CRM does.

Setting up

Featureauth The auth feature of the navree facade (part of the default full feature) brings navree-auth and the crates it builds on: crypto, db, http, validation, cache, mail and access. Add AuthModule to your app root with the modules it needs:

src/main.rs
ModuleWhy
CryptoModuleRequired. Hashes passwords with Argon2id and provides the key the JWTs are signed with.
DatabaseModuleRequired. Stores users and refresh tokens.
CacheModuleOptional. The login throttle counts attempts in the app's cache, or in a private in-process cache without it.
MailModuleOptional. Sends the password reset and verification mails of the account flows.
AuditModuleOptional. Records sign-ins and security events in the audit log.
AccessModuleOptional. Roles, permissions and abilities: see Authorization.

AuthModule::from_config() reads the auth configuration section (every key is listed under Configuration). Invalid options fail the boot with a message that names the key, never a secret value.

The application key

Sign tokens with a stable key. By default the JWT key is derived from the application key, so generate one and put it in .env:

Shell
Text
.env

navree key:generate runs your app's key:generate command (it is cargo run --quiet -- key:generate). Like every app command, it boots the whole app first, so the database must be reachable. Without an application key or auth.jwt.secret, the app still boots but signs tokens with a random key per process, so every restart signs everyone out. The log warns about it. See The signing key for the options.

Migrations

AuthModule contributes the migration set auth, which creates three tables: users, refresh_tokens and password_reset_tokens. A migration set of yours whose tables reference users runs after it:

Rust

The constant navree::auth::AUTH_MIGRATIONS holds the name auth. See Migrations.

The users table

Column
idUUID v7
nameThe display name
emailUnique; stored trimmed and in lower case, compared the same way
password_hashThe Argon2id PHC string. Never serialized, never in Debug output
email_verified_atSet by email verification
two_factor_secret, two_factor_recovery_codes, two_factor_confirmed_atTwo-factor authentication; never serialized
created_at, updated_atUTC

The entity is navree::auth::user::Entity, and navree::auth::User is its model. Its columns are fixed by navree-auth. Keep app-specific data about users in a table of your own that references users.id, and add it to the API's user resource with a user resource extension.

The auth routes

The routes are mounted under {http.api_prefix}{auth.routes_prefix}, which is /api/auth by default.

RouteAccessSuccess
POST /api/auth/registerPublic; only when auth.registration is on201: the user and a token pair
POST /api/auth/loginPublic; throttled200: the user and a token pair, or a two-factor challenge
POST /api/auth/refreshPublic200: a new token pair
POST /api/auth/logoutSigned in204
GET /api/auth/meSigned in200: the user

The account routes (changing the profile and the password, password reset, email verification and two-factor authentication) are described in Account flows. Every error uses Navree's NestJS-style format (see Errors).

Signing in

HTTP
HTTP

expires_in is the access token's lifetime in seconds. user is the user resource. An account with two-factor authentication gets a challenge instead of tokens; the client finishes the sign-in with a code, as Account flows describes.

A wrong password and an unknown email get the same answer. For an unknown email Navree still verifies the password against a dummy hash, so the response time doesn't reveal which addresses have accounts:

JSON

A missing or malformed field is a 400 validation error: without a password, the message is password should not be empty.

Calling the API

Send the access token in the Authorization header:

HTTP
JSON
RequestResponse
No token on a route that needs a user401 Unauthorized
A forged or malformed JWT, another issuer or audience, a token of another type401 Invalid token, with WWW-Authenticate: Bearer error="invalid_token"
An expired JWT401 Token expired, with the same header

A token that is present but invalid is a 401 on every route, public ones included. Navree never quietly treats such a request as a guest. A client that holds a stale access token should drop it rather than send it, in particular to login and refresh.

Refreshing tokens

HTTP
JSON

The route is public: it needs no access token. The refresh token you sent is now spent, so store the new one. Sending a spent token again is treated as theft (see Reuse detection). An unknown, expired, revoked or spent token gets:

JSON

Signing out

HTTP

The answer is always 204 No Content. What gets revoked depends on the body:

BodyRevokes
{"refresh_token": "..."}That session: every refresh token of its family, if it belongs to the caller
{"all": true}Every session of the caller, on every device
No bodyNothing (the call is still audited)

Logout needs a valid access token, so one user can't end another's session. When the app has a real-time broadcaster, the user's WebSocket subscriptions end too (see Broadcasting). The access token itself keeps working until it expires: the client throws it away.

Registering

POST /api/auth/register with {"name", "email", "password"} creates an account and signs it in: the answer is 201 Created with the same body as a login. It is public by default; turn it off with auth.registration = false. The Account flows page covers the password rules and what to do instead.

Access tokens

Access tokens are JWTs signed with HS256. Here are the header and the claims of a real one:

JSON
JSON
Claim
subThe user's id
iat, expIssued at and expires at, in Unix seconds: exp is iat plus auth.jwt.access_ttl_secs (900)
issauth.jwt.issuer (navree)
audauth.jwt.audience, only when it is configured
jtiA unique token id
typaccess, or two_factor_challenge for the two-factor login step

The claims carry no roles or permissions: authorization looks them up for each request (cached until they change), so a role change applies to the next request.

When it verifies a token, JwtService accepts only HS256 (an unsigned alg: none token is rejected) and checks:

  • the signature;
  • iss, and aud when an audience is configured (when none is, a token that carries one is rejected);
  • the token type, so a two-factor challenge token never works as an access token;
  • the expiry, with 30 seconds of leeway (navree::auth::LEEWAY_SECS) against the app's dyn Clock. A token issued more than 30 seconds in the future is rejected too.

Access tokens are stateless. Nothing revokes one before it expires: not a logout, not a password change. Keep auth.jwt.access_ttl_secs short; refresh tokens are the part that gets revoked.

You can sign and verify tokens yourself through AuthService::jwt():

Rust

Refresh tokens

A refresh token looks like <id>.<secret>: the UUID v7 of its database row, a dot and 48 random base62 characters (about 285 bits). Navree stores only the SHA-256 digest of the whole token, so a copy of the database doesn't contain usable tokens. A refresh token is valid for auth.jwt.refresh_ttl_secs (30 days).

Rotation and families

Each sign-in starts a family: one login session, on one device. Each refresh:

  1. revokes the presented token, recording its successor in replaced_by;
  2. issues the next token of the same family, valid for another 30 days from now.

The lifetime slides: a client that refreshes at least once every 30 days stays signed in. Signing out ends the family.

Reuse detection

A refresh token is single use. If a token that was already exchanged comes back, two parties hold a copy of it, and Navree can't tell which one is the legitimate client. So it:

  • revokes every token of the family, which signs the session out everywhere;
  • answers 401 Invalid refresh token;
  • writes an auth.refresh_reuse_detected security entry to the audit log.

Both the thief and the real user have to sign in again. The user's other sessions are not affected.

Two refreshes racing with the same token count as reuse too: the first one claims the token. A client that may refresh from several places at once (several tabs, parallel requests) must share one refresh call. The CRM's BFF keeps one refresh in flight per refresh token for exactly this reason.

A refresh runs in its own transaction, so the revocation of a reused family stands although the request fails. A token that is merely unknown, expired or revoked by a logout gets the same 401 and revokes nothing.

The refresh_tokens table

Column
idThe part of the token before the .
user_id, family_idThe user and the login session
token_hashSHA-256 of the whole token, lower-case hex
expires_at, revoked_atExpiry, and when it was spent or revoked
replaced_byThe id of the token it was exchanged for
ip, user_agentThe client that received it (the user agent is cut to 512 characters)
created_at

Protecting routes

Routes are denied by default: a route must declare who may call it. #[controller] refuses to compile a route without a declaration, and the app refuses to boot with a hand-written one. With navree-auth in the app, the declarations mean:

DeclarationWho may call the route
#[public]Anyone, guests included
#[authenticated]Any signed-in user
#[roles(..)], #[permissions(..)], #[can(..)]Signed-in users the access rules allow; see Authorization

Access control on routes covers the declarations in full. A guest on a protected route gets 401 Unauthorized; a signed-in user who isn't allowed gets 403 Forbidden.

The current user

CurrentUser

CurrentUser loads the signed-in user, like @CurrentUser() in NestJS:

src/modules/profile/mod.rs

It reads the users row once per request and keeps it for later extractors. It answers 401 for guests, and for a valid token whose user has been deleted since it was issued. Inside a request whose transaction is open, it reads through that transaction, so it never waits for a second database connection.

CurrentPrincipal and ClientMeta

When you need only the user's id, navree::http::CurrentPrincipal gives you the request's Principal without a database query. ClientMeta gives the client's IP address, user agent and request id, which the AuthService methods take for their audit entries:

Rust

A Principal holds user_id, via (how the request authenticated: AuthVia::Token for JWTs), token_abilities (see custom strategies) and session_id.

The user resource

The auth routes answer with UserResource:

JSON

__type is the user's subject type for abilities in the front end. To add fields to it (in GET /api/auth/me and in the user of the login and register answers), implement UserResourceExtension and register it. The CRM adds each user's roles:

apps/crm-api/crates/crm-users/src/resource.rs
Rust

Extensions run in module dependency order and may overwrite fields. AuthService::present(&user) builds the same JSON for your own routes.

Strategies

Authentication is a chain of Passport-style strategies. AuthModule registers navree-http's authenticator, which runs every registered dyn Strategy in module dependency order, the built-in JwtStrategy (named jwt) first. Each strategy answers one of three ways:

AnswerMeaning
Ok(None)The request carries no credentials of this kind. The next strategy runs; if none recognises the request, it is a guest.
Ok(Some(principal))Authenticated. Later strategies don't run.
Err(error)Credentials of this kind are present but invalid. The request fails with error (usually a 401), even on a public route. It never falls through to another strategy or to a guest.

JwtStrategy only claims Authorization: Bearer values shaped like a JWT: three base64url segments separated by dots. Other bearer values, such as an opaque API key or the scrape token of the /metrics endpoint, are left to other strategies and to route guards.

Writing your own strategy

Navree has no built-in API keys or personal access tokens. A strategy is how you add them. This one accepts an X-Api-Key header and looks the key up in an api_keys table of your own (its entity isn't shown):

src/modules/api_keys/strategy.rs

Register it in a module with strategy, from ModuleBuilderAuthExt (in the prelude):

src/modules/api_keys/mod.rs

The strategy runs after the JWT strategy, so a request with a valid bearer token is authenticated by the JWT and never reaches it. token_abilities limits what the key may do: with the access module, the user's abilities are narrowed to those permission strings (deals.read, or * for everything). None means unrestricted. See Tokens with limited abilities.

AuthService

AuthService is the API behind the routes, for seeders, commands and your own controllers. It is global, so any provider can inject Arc<AuthService>.

MethodWhat it does
register(&uow, NewUser { name, email, password })Creates an account with the password policy, in your unit of work
create_user(name, email, password)Creates an account without the password policy (seeders, commands, tests)
validate_user(email, password)The user, or AuthError::InvalidCredentials
attempt_login(email, password, &meta)The login flow: throttle, password check, then LoginOutcome::Authenticated { user, tokens } or LoginOutcome::TwoFactorRequired { user, challenge_token }
login(email, password, &meta)attempt_login for accounts without two-factor authentication; AuthError::TwoFactorRequired for the others
issue_tokens(&user, meta)A new session: an access token and the first refresh token of a new family
refresh(refresh_token, meta)Rotates a refresh token
logout(refresh_token, user_id, all)Ends one session, or all of them
revoke_all_refresh_tokens(user_id)Ends every session of a user
find_user(id), find_by_email(email)Look up users
update_password(user_id, password)Sets a password with the policy; sessions are left alone
present(&user)The user resource, with its extensions
jwt()The JwtService

The account flows add more methods; see Account flows. Writes join the unit of work of the current task when there is an unfinished one (a request's, say) and otherwise commit their own transaction.

This console command creates an admin account with a random password, using the access module to assign the role:

src/modules/admin/commands.rs

Register it with m.command(create_admin()) and run it with navree admin:create [email protected] Ada Lovelace. See Console commands.

The signing key: JWT secret or APP_KEY

AuthModule picks the HS256 signing key when the app boots, in this order:

  1. auth.jwt.secret, when it is set (in .env as APP_AUTH__JWT__SECRET). It must be at least 32 bytes long; a shorter one fails the boot.
  2. A key derived from the application key (app.key, or APP_KEY): an HMAC-SHA-256 of the key for the purpose navree-auth.jwt, from Encrypter::derive_key. The application key itself never signs anything.
  3. A random key per process, with a warning in the log. Tokens stop working when the process restarts.

The derived key is the default, and it is enough for most apps: one secret to manage, and rotating APP_KEY rotates the JWT key with it. Set a dedicated auth.jwt.secret when you want to rotate the two independently.

Rotating the signing key, either way, invalidates every access token: requests get 401 Invalid token. Refresh tokens don't depend on the key (they are stored as digests), so clients refresh and carry on. app.previous_keys doesn't help here: keys are derived from the current application key only.

The login throttle

POST /api/auth/login allows auth.login_throttle_per_minute (5) failed attempts per email address and client IP address in a one-minute window. The next attempt, even with the right password, gets a 429 until the window ends:

HTTP
  • The same account from another address, and other accounts from the same address, are not affected.
  • Each attempt is counted before the password is checked, so a burst of parallel guesses can't get more through than the limit. A successful login clears the count.
  • A wrong current_password when changing the email or the password, or a wrong password when turning two-factor authentication off, counts against the same limit.
  • The first refusal in each window is written to the audit log as auth.throttled (kind security), with the email address in its metadata.
  • auth.login_throttle_per_minute = 0 turns the throttle off.

The counters live in the app's cache when CacheModule is in the app, and in a private in-process cache otherwise. The only cache store today is the in-memory one (Redis drivers are on the roadmap), so with several API instances each counts attempts on its own.

The throttle is only as good as the client address. Behind a reverse proxy or a BFF, list the proxy in http.trusted_proxies, or every client shares the proxy's address and one person's wrong passwords lock everyone out of that account. See Rate limiting.

Registration, the forgot-password route and the reset-password route have no attempt throttle of their own (forgot-password only limits how often it mails one address). Only navree-http's per-IP limit (http.per_ip_rate_limit, 600 requests a minute by default) applies to them.

Browser apps: the BFF pattern

A token in localStorage can be read by any script that runs on the page, so one cross-site scripting bug leaks every session. navree-auth has no cookie-based login of its own. For a web front end, use a backend-for-frontend (BFF): the browser talks only to your web server, on the same origin, and never sees a token. The web server keeps the tokens in httpOnly cookies and calls Navree with Authorization: Bearer.

The CRM's front end, apps/crm-web, is a Next.js app with a BFF in src/lib/bff:

  • Sign-in. The browser posts its credentials to the BFF's /api/auth/login. The BFF calls Navree, stores the access token in a navree_at cookie (its Max-Age is expires_in) and the refresh token in navree_rt (30 days), both HttpOnly; SameSite=Lax and Secure in production, and returns only {"user": ...}. A two-factor challenge waits in a navree_2fa cookie that only /api/auth/2fa receives.
  • Requests. Every other /api/* call is forwarded with the bearer token from the cookie. On a 401 the BFF refreshes once, updates both cookies and retries. Concurrent requests share one refresh call, because a second use of a refresh token would revoke the session. If the refresh fails, it clears the cookies and the browser goes back to the sign-in page.
  • Token responses. When an /api/auth/* answer carries a token pair (changing the password returns one), the BFF puts it in the cookies and strips it from the body.
  • Sign-out. The BFF revokes the refresh token at Navree, then clears the cookies.
  • CSRF. Cookies are sent automatically, so every non-GET request must come from the app's own origin; anything else gets a 403.
  • Client addresses. The BFF never passes on the browser's own X-Forwarded-For, which anyone can forge. It sends the browser's address only when reverse proxies in front of it vouch for it: set NAVREE_TRUSTED_PROXY_HOPS to their number.
apps/crm-web/src/lib/bff/bff.ts

On the API side, list the BFF's address in http.trusted_proxies so the API believes the X-Forwarded-For it sends. The login throttle and the per-IP limits then apply per browser. When the BFF knows no client address, every browser reaches the API from the BFF's address and shares its limits.

apps/crm-api/config/app.toml

A tour of the CRM shows the BFF in context. Native and mobile clients call the API directly and keep the tokens in the platform's secure storage.

Configuration

Every key is optional. The defaults:

config/app.toml
KeyDefault
auth.registrationtrueMounts POST /auth/register
auth.login_throttle_per_minute5Failed logins per email and IP per minute; 0 turns it off
auth.password_min_length8In characters; at least 1
auth.routes_prefix"/auth"Under http.api_prefix; empty or starting with /
auth.jwt.secretunsetAt least 32 bytes; unset means a key derived from app.key
auth.jwt.issuer"navree"The iss claim written and required; not empty
auth.jwt.audienceunsetThe aud claim written and required
auth.jwt.access_ttl_secs900Access token lifetime; at least 1
auth.jwt.refresh_ttl_secs2592000Refresh token lifetime (30 days); at least 1
auth.password_reset.*See Password reset
auth.verification.*See Email verification
auth.two_factor.*See Two-factor authentication

Related settings: app.key (see Encryption and hashing), the hashing section for password hashes, http.trusted_proxies for client addresses, and http.signing_key for the signed verification links.

From the environment, prefix the key with APP_ and use __ between levels: APP_AUTH__JWT__SECRET, APP_AUTH__REGISTRATION=false. Keep secrets in .env, never in config/app.toml. A new app's config/app.toml sets auth.registration, and .env doesn't override it, so change that one in the TOML file or with a real environment variable.

To configure the module in code instead, pass AuthOptions to AuthModule::for_root:

Rust

Audit entries

With an audit log in the app, the module records:

ActionKindWhen
auth.registerauthAn account was registered (after the commit)
auth.loginauthA sign-in succeeded (metadata.two_factor says totp or recovery_code after a second factor)
auth.login_failedsecurityWrong credentials; metadata.email, and the user as actor when the email is known
auth.throttledsecurityThe first refusal of a throttle window; metadata.flow and, for logins, metadata.email
auth.refreshauthA refresh token was rotated; metadata.family_id
auth.refresh_reuse_detectedsecurityA spent refresh token came back and its family was revoked
auth.logoutauthmetadata.all and metadata.revoked (how many tokens)

Entries carry the client's IP address, user agent and request id. Passwords and tokens are never recorded. The account flows add their own entries; see Account flows.

Testing

TestApp::acting_as works with the test authenticator, which AuthModule replaces. In an app with AuthModule, create a user and sign a real token for it:

src/modules/profile/tests.rs
  • To test the sign-in itself, post to /api/auth/login and read the tokens from the response. The CRM's test kit does this in TestCrm::login_as.
  • TestAppBuilder::fake_auth() puts the test authenticator back, so acting_as works, but then real bearer tokens don't: you can't mix the two in one app.
  • Override dyn Clock with a FixedClock to test expiry. The login throttle's windows run on the cache's timer, though, not on the app's clock: moving a FixedClock forward doesn't end a lockout. Use different email addresses, or set auth.login_throttle_per_minute to 0 in tests that don't test the throttle.

See HTTP tests for the test client.

Limits

  • No session login. navree-auth issues tokens only. Cookie sessions with CSRF protection are a separate, optional module (Sessions and CSRF) that navree-auth doesn't use.
  • No API tokens. There are no personal access tokens or API keys; write a strategy for them.
  • No passkeys, social login or OAuth2 server. They are deferred; see the roadmap.
  • Access tokens can't be revoked before they expire, and they are signed with HS256 only.
  • Refresh tokens are never pruned from the database.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks