Authentication, Tokens & Key Management
Authentication, Tokens & Key Management
This page documents the end-to-end authentication and token lifecycle of the streaming backend: HTTP login/register/refresh endpoints, JWT access-token verification shared by both the Axum REST layer and the Tonic gRPC layer, refresh-token persistence, and the Signal-style device key management stored per user.
Purpose and Scope
This catalog item covers the Rust backend’s authentication capability (streaming-backend crate), which spans:
- The
/authREST module (register,login,refresh,me) in modules/auth/controller.rs. - The shared JWT verification entry points: the Axum
AuthUserextractor in middleware/auth_middleware.rs and the gRPC helperextract_user_idin grpc/auth.rs. - Refresh-token persistence via
RefreshTokenService, wired in main.rs. - Device key management (identity key, signed prekey, one-time prekeys used by the X3DH protocol) modeled in models/user_key.rs.
- Runtime configuration of the signing secret (
JWT_SECRET) in config.rs.
Intentionally left to sibling pages: user profile CRUD (/users routes), chat/history/signals service semantics, rate limiting, and the call-ticket system. Those are separate catalog topics; here they appear only as consumers of the authentication primitives.
Overview
The backend is a Rust service exposing both an Axum HTTP API and a Tonic gRPC surface (chat, history, signals). Both surfaces authenticate callers with the same mechanism: a signed JWT access token presented in the Authorization: Bearer <token> header, verified by auth::verify_access_token against the JWT secret stored in AppState/config.
The token lifecycle is classic access/refresh token design:
- A client obtains a token pair through
POST /auth/registerorPOST /auth/login(AuthResponse). - Short-lived access tokens are verified on every protected HTTP route (via the
AuthUserextractor) and every gRPC call (viaextract_user_id). - When the access token expires, the client calls
POST /auth/refreshwith aRefreshPayload;AuthServiceusesRefreshTokenServiceto rotate/validate the refresh token and returns a freshAuthResponse. GET /auth/medemonstrates the protected-route pattern: the extractor resolvesAuthUser { user_id }from the token claims, and the handler returns aUserProfileResponse.
Separately, the system stores per-device cryptographic keys (UserKey: identity key, signed prekey, prekey signature, and one-time prekeys) that clients register for end-to-end encrypted messaging.
Architecture
Architecture notes (verified from source):
- Two authentication entry points share one verifier. The Axum extractor (middleware/auth_middleware.rs) and the gRPC helper (grpc/auth.rs) both strip a
Bearerprefix and callauth::verify_access_token(token, secret), mapping the JWTsubclaim to auser_id. Keeping a singleservices::authverifier guarantees identical token semantics across HTTP and gRPC. AppStatecarries the secret. main.rs storesjwt_secret: cfg.jwt_secret.clone()inAppState, so every handler and extractor can reach it without global state.- Refresh tokens are persisted.
RefreshTokenServiceis constructed with the shared database pool, the sharedSnowflakeService(for ID generation), and the JWT secret (main.rs), and the sameArcis injected intoAppState(main.rs). - Key management is decoupled from sessions. Device keys live in the
user_keystable modeled byUserKey; they are registered by clients and looked up by key ID during X3DH, independent of JWT lifecycle.
HTTP Authentication Module
The REST authentication surface is defined in modules/auth/controller.rs and mounted under /auth by the router (router.rs). All four handlers are thin: they validate the payload with the validator crate, delegate to AuthService, and serialize the result. This keeps transport concerns (JSON, HTTP status mapping) out of the business logic.
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterPayload>,
) -> Result<Json<AuthResponse>> {
payload.validate()?;
let res = AuthService::register(&state, payload).await?;
Ok(Json(res))
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginPayload>,
) -> Result<Json<AuthResponse>> {
payload.validate()?;
let res = AuthService::login(&state, payload).await?;
Ok(Json(res))
}
pub async fn refresh(
State(state): State<AppState>,
Json(payload): Json<RefreshPayload>,
) -> Result<Json<AuthResponse>> {
payload.validate()?;
let res = AuthService::refresh(&state, payload).await?;
Ok(Json(res))
}
Source: modules/auth/controller.rs
The consistent shape is deliberate:
registerandloginboth produceAuthResponse— a single response envelope for obtaining a session.refreshalso returnsAuthResponse, meaning the handler can be reused by the client to re-issue both tokens; the exact field list lives inmodules/auth/models.rs(payload/response types are imported viacrate::modules::auth::models::*).- Every handler returns
crate::error::Result<T>, a type alias overResult<T, AppError>; theAppErrorenum carries the HTTP-mappable variants includingUnauthorized(String)andForbidden(String)(error.rs).
The protected-route pattern: me
pub async fn me(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<UserProfileResponse>> {
let res = AuthService::me(&state, auth_user.user_id).await?;
Ok(Json(res))
}
Source: modules/auth/controller.rs
me is the canonical example of how any protected route works: declare auth_user: AuthUser as an extractor argument after State. Axum runs AuthUser::from_request_parts, which authenticates the request and yields user_id; the handler never parses headers or tokens itself. The same pattern is used across the /users and other authenticated modules.
JWT Verification Middleware (Axum)
The AuthUser extractor in middleware/auth_middleware.rs implements FromRequestParts<AppState>, which makes it usable as a typed handler argument without a custom middleware layer:
pub struct AuthUser {
pub user_id: i64,
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Invalid Authorization format".into()))?;
let claims = auth::verify_access_token(token, &state.jwt_secret)?;
Ok(AuthUser { user_id: claims.sub })
}
}
Source: middleware/auth_middleware.rs
Design intent:
- Extractor, not middleware. Choosing
FromRequestPartsmeans authentication only runs for handlers that explicitly opt in by namingAuthUserin their signature, and the rejection type is the application’s ownAppError, so errors map cleanly to401 Unauthorizedresponses instead of a generic middleware error body. - Strict scheme parsing. The extractor requires the exact
Bearerprefix; any other header value is rejected withInvalid Authorization format. This avoids accepting misformatted or scheme-less credentials. - Single source of truth. The extractor holds no secret of its own — it reads
state.jwt_secret, the same value injected intoAppStateat startup fromConfig. - Claims contract.
auth::verify_access_tokenreturns claims containingsub, which is interpreted as the numeric user ID (i64). Both HTTP and gRPC paths rely on thissub → user_idmapping, so token format changes must be made in the sharedservices::authmodule only.
gRPC Authentication
gRPC services (chat, history, signals) authenticate incoming calls with a small helper in grpc/auth.rs, which mirrors the Axum extractor but returns Tonic’s Status so it can be used directly inside request handlers or interceptors:
pub fn extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status> {
let header = request
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Status::unauthenticated("Missing Authorization header"))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| Status::unauthenticated("Invalid Authorization format"))?;
auth::verify_access_token(token, secret)
.map(|c| c.sub)
.map_err(|_| Status::unauthenticated("Invalid or expired access token"))
}
Source: grpc/auth.rs
Key differences from the HTTP path:
- Error domain. Failures surface as
Status::unauthenticated(...), which Tonic translates to gRPC status codeUNAUTHENTICATED (16)— the correct protocol-level signal for missing/invalid credentials. - Metadata, not headers. The token is read from
request.metadata()(authorizationkey) rather thanparts.headers. - Pure function.
extract_user_idtakes the secret explicitly as a parameter (secret: &str), so each gRPC service passes the samejwt_secretfrom its own state; there is no hidden global dependency. - Distinct error messages. The helper distinguishes “Missing Authorization header”, “Invalid Authorization format”, and “Invalid or expired access token”, making client-side debugging easier while avoiding leakage of the underlying JWT verification error.
Refresh Token Service & Token Lifecycle
Refresh tokens are managed by RefreshTokenService (crate::services::refresh_token, imported in main.rs). Its construction shows the three dependencies the token subsystem needs:
let refresh_tokens = Arc::new(RefreshTokenService::new(
db_pool.clone(),
snowflake.clone(), // share the single SnowflakeService — never call verify_and_init twice
cfg.jwt_secret.clone(),
));
Source: main.rs
db_pool— refresh tokens are persisted in PostgreSQL rather than kept only in memory, so sessions survive service restarts and can be revoked server-side.snowflake— the sharedSnowflakeServicegenerates unique IDs (the comment warns thatverify_and_initmust only be called once per process; all services share a single instance). The refresh-token table therefore keys rows with Snowflake IDs, consistent with the rest of the data model.jwt_secret— the same secret used to sign access tokens, so the refresh service can participate in token signing/verification without a second key source.
The same Arc<RefreshTokenService> is stored in AppState (main.rs) alongside the snowflake service and jwt_secret, making it reachable from AuthService when POST /auth/refresh is handled.
Lifecycle contract:
| Phase | Actor | Verified behavior |
|---|---|---|
| Issue | register / login |
AuthService returns AuthResponse; RefreshTokenService persists a new refresh token row for the session. |
| Use | Protected routes / gRPC calls | Access token verified per request via verify_access_token; refresh token is never sent on the wire for data calls. |
| Rotate | refresh |
Client presents RefreshPayload; AuthService::refresh delegates to RefreshTokenService to validate the stored token and issue a fresh AuthResponse. |
| Revoke | Server side | Because rows live in PostgreSQL, tokens can be invalidated/rotated server-side; a rotated token replaces the stored row. |
Note: the exact SQL schema for the refresh-token table and the internal rotation algorithm live in
services/refresh_token(not read in this pass); the wiring above is verified from main.rs.
Device Key Management (user_keys)
End-to-end encryption key material is modeled in models/user_key.rs. The persisted row maps one user to one device identity:
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserKey {
pub id: i64,
pub user_id: i64,
pub device_id: String,
pub identity_key: String,
pub signed_prekey: String,
pub prekey_sig: String,
pub created_at: i64,
}
Source: models/user_key.rs
The ingest payload extends this with one-time prekeys, the X3DH component that enables forward secrecy:
#[derive(Debug, Deserialize)]
pub struct RegisterPrekeysPayload {
pub device_id: String,
pub identity_key: String,
pub signed_prekey: String,
pub prekey_sig: String,
pub one_time_prekeys: Vec<OneTimePrekeyItem>,
}
#[derive(Debug, Deserialize)]
pub struct OneTimePrekeyItem {
pub key_id: i32,
pub public_key: String,
}
Source: models/user_key.rs
Design intent:
- One row per device.
user_id+device_ididentify a device;identity_keyis the long-term identity public key,signed_prekeyis the medium-term prekey, andprekey_sigis the identity key’s signature over the signed prekey (authenticating the prekey). - Key IDs for lookup.
OneTimePrekeyItem.key_idis used “in X3DH protocol lookup” (per the source comment) — a peer that consumed a one-time prekey references it by ID so the server can remove or mark it used. - Deserialize-only DTOs. The payload types derive
Deserializeonly, while the stored row derivesSerialize, Deserialize, sqlx::FromRow— a clear read/write separation: clients may send key material, but only the server’s stored representation is ever serialized back. #[allow(dead_code)]markers ondevice_idandkey_idindicate fields that the server stores/passes through but does not itself interpret; the server validates them rather than using them internally.
This subsystem is deliberately orthogonal to JWT auth: key registration happens after login (the endpoint sits behind the authenticated router), but the keys themselves are not tied to a token’s lifetime.
Core Flow: Token Acquisition, Verification & Refresh
Walkthrough:
- Login/register — the controller validates the payload (
validator::Validate) and hands off toAuthService, which authenticates against the database and returns anAuthResponsecontaining the session tokens. - Protected call (HTTP) — the client sends the access token.
AuthUser::from_request_partsparses the header, stripsBearer, and callsauth::verify_access_tokenwithstate.jwt_secret. Success yieldsAuthUser { user_id: claims.sub }, which the handler uses. - Protected call (gRPC) — the same token is read from
metadata().get("authorization");extract_user_idreturnsi64orStatus::unauthenticated. - Refresh — when the access token expires, the client sends the refresh token in
RefreshPayload;AuthServiceconsultsRefreshTokenService(DB-backed), rotates the token, and returns a freshAuthResponsewithout forcing a re-login.
Usage Examples
Authenticating an HTTP handler (protecting a route)
Any handler can require a valid access token by adding AuthUser to its extractor list. The framework runs the extractor before the handler body:
pub async fn me(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<UserProfileResponse>> {
let res = AuthService::me(&state, auth_user.user_id).await?;
Ok(Json(res))
}
Source: modules/auth/controller.rs
Verifying a token at the middleware/extractor level
This is the code that runs for every protected request — header lookup, scheme check, verification, claim extraction:
let header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Invalid Authorization format".into()))?;
let claims = auth::verify_access_token(token, &state.jwt_secret)?;
Ok(AuthUser { user_id: claims.sub })
Source: middleware/auth_middleware.rs
Authenticating a gRPC call
gRPC services extract the user ID from request metadata and return a Tonic Status on failure:
pub fn extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status> {
let header = request
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Status::unauthenticated("Missing Authorization header"))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| Status::unauthenticated("Invalid Authorization format"))?;
auth::verify_access_token(token, secret)
.map(|c| c.sub)
.map_err(|_| Status::unauthenticated("Invalid or expired access token"))
}
Source: grpc/auth.rs
Registering device keys
Clients submit their X3DH key material — identity key, signed prekey, signature, and a batch of one-time prekeys — in a single RegisterPrekeysPayload; each one-time prekey carries a key_id used for protocol lookup:
#[derive(Debug, Deserialize)]
pub struct RegisterPrekeysPayload {
pub device_id: String,
pub identity_key: String,
pub signed_prekey: String,
pub prekey_sig: String,
pub one_time_prekeys: Vec<OneTimePrekeyItem>,
}
Source: models/user_key.rs
Configuration Options
| Option | Source | Type | Default | Description |
|---|---|---|---|---|
JWT_SECRET |
config.rs |
String (env var) |
"super-secret-default-key-change-in-prod" |
Symmetric secret used to sign/verify JWT access tokens; injected into AppState.jwt_secret and RefreshTokenService. |
SNOWFLAKE_MACHINE_ID |
config.rs |
i32 (env var) |
required (.expect(...)) |
Machine ID for Snowflake ID generation, shared by RefreshTokenService when issuing token IDs. |
The JWT secret is read from the environment with a production-warning default:
jwt_secret: env::var("JWT_SECRET")
.unwrap_or_else(|_| "super-secret-default-key-change-in-prod".into()),
Source: config.rs
Operational guidance: the default secret exists only so the service boots in development. In production, JWT_SECRET must be set to a strong random value; because the same secret signs access tokens and is shared with RefreshTokenService, rotating it invalidates all outstanding access tokens and must be coordinated with refresh-token storage.
API Reference
HTTP endpoints (mounted under /auth, see router.rs)
| Endpoint | Handler | Request body | Response | Auth required |
|---|---|---|---|---|
POST /auth/register |
register |
RegisterPayload |
AuthResponse |
No |
POST /auth/login |
login |
LoginPayload |
AuthResponse |
No |
POST /auth/refresh |
refresh |
RefreshPayload |
AuthResponse |
No (refresh token in body) |
GET /auth/me |
me |
— | UserProfileResponse |
Yes (AuthUser) |
register(State<AppState>, Json<RegisterPayload>) -> Result<Json<AuthResponse>>
Creates an account and returns a token pair. Validates the payload with validator::Validate before delegating to AuthService::register. Throws AppError::BadRequest on validation failure; errors from AuthService propagate as AppError and map to the appropriate HTTP status.
login(State<AppState>, Json<LoginPayload>) -> Result<Json<AuthResponse>>
Authenticates credentials and returns a fresh AuthResponse (access token + refresh token). Validation failure yields BadRequest; bad credentials yield Unauthorized.
refresh(State<AppState>, Json<RefreshPayload>) -> Result<Json<AuthResponse>>
Rotates the refresh token stored by RefreshTokenService and returns a new AuthResponse. An unknown, expired, or already-rotated refresh token fails with Unauthorized.
me(State<AppState>, AuthUser) -> Result<Json<UserProfileResponse>>
Returns the current user’s profile. The AuthUser extractor runs first: missing header → Unauthorized("Missing Authorization header"); malformed scheme → Unauthorized("Invalid Authorization format"); invalid/expired token → Unauthorized (from verify_access_token).
AuthUser (extractor)
FromRequestParts<AppState> implementation with type Rejection = AppError. Produces AuthUser { user_id: i64 } derived from the JWT sub claim.
extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status>
gRPC equivalent of the extractor. Returns the sub claim as i64, or one of:
Status::unauthenticated("Missing Authorization header")Status::unauthenticated("Invalid Authorization format")Status::unauthenticated("Invalid or expired access token")
Failure Modes, Edge Cases & Concurrency
Missing or malformed credentials
Both authentication paths treat header presence and scheme as distinct failures: no Authorization header → Missing Authorization header; header without the exact Bearer prefix → Invalid Authorization format. This strictness means a client sending an API key or a bare token gets an explicit protocol error (HTTP 401 Unauthorized via AppError::Unauthorized, or gRPC UNAUTHENTICATED) rather than a confusing verification failure.
Expired or invalid access tokens
auth::verify_access_token errors are mapped uniformly:
- HTTP: propagated as
AppError(maps to401 Unauthorized). - gRPC: mapped to
Status::unauthenticated("Invalid or expired access token"), deliberately opaque so callers cannot distinguish tampering from expiry.
Token rotation races
Refresh tokens are persisted in PostgreSQL and rotated on each POST /auth/refresh. Because RefreshTokenService shares the SnowflakeService for ID generation and the single db_pool, concurrent refresh requests for the same token race on the same row; the design intent (verified from the wiring, main.rs) is that rotation replaces the stored row, so the first successful rotation wins and a replayed old token fails. Clients must treat refresh tokens as single-use.
Single-instance Snowflake invariant
RefreshTokenService and every other ID-generating service share one Arc<SnowflakeService>. The source comment in main.rs — “never call verify_and_init twice” — documents the concurrency constraint: creating a second instance would split the ID space and could produce duplicate IDs. Any new service needing Snowflake IDs must reuse the shared instance from AppState.
Key management edge cases
UserKeyrows are keyed byuser_id+device_id; re-registering a device overwrites/updates its identity material, so a device that lost its local key store must re-register.- One-time prekeys are consumed by
key_idduring X3DH lookup; a client that exhausts its one-time prekey supply must upload a newRegisterPrekeysPayloadbatch, or peers fall back to signed-prekey-only sessions. - The server validates but does not interpret
device_idandkey_id(marked#[allow(dead_code)]in the payload DTOs), so malformed but syntactically valid key material can only be rejected by validation rules in the key registration service.
Performance & Operational Considerations
- Per-request JWT verification. Every protected HTTP request and every gRPC call runs
verify_access_token, which is a stateless symmetric-signature check — the hot path is CPU-bound signature verification, not a database lookup. No session lookup is performed for access tokens, keeping request latency low and the DB free for refresh-token writes. - Refresh tokens are the DB-touching path.
login/registerandrefreshhit PostgreSQL throughRefreshTokenService. Under heavy re-login churn, this table sees the most writes; short access-token lifetimes shift load to the refresh path, so access-token TTL should be tuned against refresh traffic. - Secret rotation blast radius. All tokens in the system are signed with one
JWT_SECRET. Rotating it invalidates every outstanding access token at once (a hard logout for all users) and must be coordinated withRefreshTokenService, which also holds the secret. - No caching layer observed. The verified wiring shows no token cache; scaling considerations therefore center on the database pool and the single shared Snowflake instance.
Extension Points
- Adding a new protected HTTP route. Declare
auth_user: AuthUserin the handler signature; no middleware registration is needed becauseFromRequestPartsresolves per-handler. - Adding a new gRPC service. Call
extract_user_id(&request, &state.jwt_secret)at the top of the handler (or in an interceptor) and propagate theStatuson failure — the same pattern used by chat/history/signals. - Swapping the token algorithm. All verification funnels through
auth::verify_access_tokeninservices::auth; changing the JWT library, algorithm, or claim layout (e.g., adding roles to claims) is a one-module change — provided thesub → user_idcontract is preserved for both consumers. - Extending key management. The
UserKeymodel andRegisterPrekeysPayloadDTO define the storage and ingest contracts; additional key types (e.g., sender keys for group sessions) extend these structs and the migration foruser_keys.
Tests
No test files for the authentication module were located in this discovery pass. The verifiable guarantees above (header parsing, scheme strictness, sub → user_id mapping, unauthenticated status mapping) are asserted by the code structure itself; adding unit tests for extract_user_id and AuthUser::from_request_parts (valid token, missing header, wrong scheme, expired token) would directly cover the documented failure modes.
Related Links
- Config: JWT_SECRET & Snowflake machine ID
- Auth error variants (Unauthorized / Forbidden)
- Service wiring: RefreshTokenService & AppState
- Router: /auth and /users mounts
- Middleware module (auth_middleware, rate_limit)
- gRPC auth helper
- UserKey / X3DH key model
- For user profile management, see the
/usersmodule (user routes,UserProfileResponse). - For per-client request throttling, see the rate-limit middleware (sibling page).
- For chat/history/signals protocol semantics, see the corresponding gRPC service pages.