Introduction
Actix Security is a comprehensive authentication and authorization framework for Actix Web, inspired by Spring Security. It provides a familiar, declarative approach to securing your Rust web applications.
Why Actix Security?
If you're coming from the Java/Spring ecosystem, you'll feel right at home. Actix Security brings Spring Security's powerful concepts to Rust:
- Declarative Security - Use attribute macros like
#[secured],#[pre_authorize], and#[roles_allowed] - Expression Language - Write security rules like
hasRole('ADMIN') OR hasAuthority('users:write') - Pluggable Architecture - Easily swap authentication and authorization implementations
- Zero Runtime Overhead - Security expressions are compiled at build time
Features
Authentication
- In-memory user store for development and testing
- HTTP Basic authentication
- Pluggable password encoders (Argon2, NoOp, Delegating)
- Custom authenticator support via traits
Authorization
- URL pattern-based authorization (regex support)
- Method-level security with attribute macros
- Role-based access control (RBAC)
- Fine-grained authority/permission checks
- Spring Security Expression Language (SpEL-like)
Security Macros
| Macro | Spring Equivalent | Description |
|---|---|---|
#[secured("ADMIN")] | @Secured("ROLE_ADMIN") | Role-based access |
#[pre_authorize(...)] | @PreAuthorize(...) | Expression-based access |
#[permit_all] | @PermitAll | Public access |
#[deny_all] | @DenyAll | Deny all access |
#[roles_allowed("ADMIN")] | @RolesAllowed("ADMIN") | Java EE style |
Additional Features
- Security headers middleware (CSP, HSTS, X-Frame-Options, etc.)
- Security context for accessing the current user anywhere
- Extensible expression language
Quick Example
use actix_web::{get, App, HttpServer, HttpResponse, Responder};
use actix_security::{secured, pre_authorize};
use actix_security::http::security::{
AuthenticatedUser, AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User,
};
use actix_security::http::security::middleware::SecurityTransform;
// Role-based security
#[secured("ADMIN")]
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Welcome, Admin {}!", user.get_username()))
}
// Expression-based security
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[get("/posts/new")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Create a new post")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let encoder = Argon2PasswordEncoder::new();
HttpServer::new(move || {
let enc = encoder.clone();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || {
AuthenticationManager::in_memory_authentication()
.password_encoder(enc.clone())
.with_user(
User::with_encoded_password("admin", enc.encode("secret"))
.roles(&["ADMIN".into(), "USER".into()])
.authorities(&["posts:write".into()])
)
})
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
})
)
.service(admin_dashboard)
.service(create_post)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Getting Help
- GitHub Issues - Bug reports and feature requests
- API Documentation - Detailed API reference
License
This project is licensed under the MIT License.
Quick Start
Get up and running with Actix Security in 5 minutes.
Prerequisites
- Rust 1.70 or later
- Cargo
Step 1: Add Dependencies
Add the following to your Cargo.toml:
[dependencies]
actix-web = "4"
actix-security = { version = "0.2", features = ["argon2", "http-basic"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Step 2: Create a Simple Secured Application
use actix_web::{get, web, App, HttpServer, HttpResponse, Responder};
use actix_security::secured;
use actix_security::http::security::{
AuthenticatedUser, AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User,
};
use actix_security::http::security::middleware::SecurityTransform;
// Public endpoint
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome! Login at /login")
}
// Secured endpoint - requires USER role
#[secured("USER")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Hello, {}!", user.get_username()))
}
// Admin-only endpoint
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Admin Panel - Welcome {}!", user.get_username()))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("Starting server at http://127.0.0.1:8080");
println!("Try: curl -u user:password http://127.0.0.1:8080/profile");
println!("Try: curl -u admin:admin http://127.0.0.1:8080/admin");
let encoder = Argon2PasswordEncoder::new();
HttpServer::new(move || {
let enc = encoder.clone();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || {
AuthenticationManager::in_memory_authentication()
.password_encoder(enc.clone())
.with_user(
User::with_encoded_password("user", enc.encode("password"))
.roles(&["USER".into()])
)
.with_user(
User::with_encoded_password("admin", enc.encode("admin"))
.roles(&["ADMIN".into(), "USER".into()])
)
})
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
})
)
.service(index)
.service(profile)
.service(admin)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Step 3: Test Your Application
# Run the server
cargo run
# Test public endpoint
curl http://127.0.0.1:8080/
# Test with user credentials
curl -u user:password http://127.0.0.1:8080/profile
# Output: Hello, user!
# Test admin endpoint with user (should fail)
curl -u user:password http://127.0.0.1:8080/admin
# Output: 403 Forbidden
# Test admin endpoint with admin
curl -u admin:admin http://127.0.0.1:8080/admin
# Output: Admin Panel - Welcome admin!
What's Next?
- Learn about Installation options
- Build Your First Secured App step by step
- Explore Authentication options
- Understand Authorization patterns
- Master Security Macros
Installation
Cargo Dependencies
Add the following to your Cargo.toml:
[dependencies]
actix-security = "0.2"
Feature Flags
| Feature | Default | Description |
|---|---|---|
macros | ✓ | Procedural macros (#[secured], #[pre_authorize], etc.) |
argon2 | ✓ | Enables Argon2 password encoding |
http-basic | ✓ | Enables HTTP Basic authentication |
jwt | Enables JWT authentication | |
session | Enables Session-based authentication | |
oauth2 | Enables OAuth2/OIDC authentication | |
full | All features enabled |
Minimal Installation
For a minimal installation without optional features:
[dependencies]
actix-security = { version = "0.2", default-features = false }
Full Installation
For all features:
[dependencies]
actix-security = { version = "0.2", features = ["full"] }
Compatibility
| Actix Security | Actix Web | Rust |
|---|---|---|
| 0.1.x | 4.x | 1.70+ |
Crate Overview
The actix-security crate provides:
Core Features:
- Security middleware (
SecurityTransform) - Authentication (
MemoryAuthenticator,Authenticatortrait) - Authorization (
RequestMatcherAuthorizer,Authorizertrait) - Password encoding (
Argon2PasswordEncoder,DelegatingPasswordEncoder) - User model (
User,AuthenticatedUser) - Security headers middleware (
SecurityHeaders) - Security context (
SecurityContext) - Expression evaluation
Procedural Macros (with macros feature):
#[secured]- Role-based method security#[pre_authorize]- Expression-based method security#[permit_all]- Mark endpoints as public#[deny_all]- Block all access#[roles_allowed]- Java EE style role checks
Verifying Installation
Create a simple test to verify everything is working:
use actix_security::http::security::{
AuthenticationManager, Argon2PasswordEncoder, PasswordEncoder, User
};
use actix_security::secured;
#[test]
fn test_installation() {
// Test password encoding
let encoder = Argon2PasswordEncoder::new();
let encoded = encoder.encode("test");
assert!(encoder.matches("test", &encoded));
// Test user creation
let user = User::with_encoded_password("test", encoded)
.roles(&["USER".into()]);
assert_eq!(user.username, "test");
assert!(user.roles.contains(&"USER".into()));
}
Run with:
cargo test test_installation
Your First Secured App
This guide walks you through building a complete secured application step by step.
What We'll Build
A simple REST API with:
- Public endpoints (no auth required)
- User-only endpoints (requires USER role)
- Admin-only endpoints (requires ADMIN role)
- Authority-based endpoints (requires specific permissions)
Project Setup
cargo new my-secured-app
cd my-secured-app
Update Cargo.toml:
[package]
name = "my-secured-app"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
actix-security = { version = "0.2", features = ["argon2", "http-basic"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Step 1: Define Your Users
First, create a function that configures your user store:
use actix_security::http::security::{
AuthenticationManager, Argon2PasswordEncoder, PasswordEncoder, User,
};
use actix_security::http::security::web::MemoryAuthenticator;
fn create_authenticator(encoder: Argon2PasswordEncoder) -> MemoryAuthenticator {
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
// Admin user with full access
.with_user(
User::with_encoded_password("admin", encoder.encode("admin123"))
.roles(&["ADMIN".into(), "USER".into()])
.authorities(&[
"users:read".into(),
"users:write".into(),
"posts:read".into(),
"posts:write".into(),
])
)
// Regular user
.with_user(
User::with_encoded_password("user", encoder.encode("user123"))
.roles(&["USER".into()])
.authorities(&["posts:read".into()])
)
// Guest with limited access
.with_user(
User::with_encoded_password("guest", encoder.encode("guest123"))
.roles(&["GUEST".into()])
)
}
Step 2: Configure URL-Based Authorization
Create rules for URL patterns:
use actix_security::http::security::{AuthorizationManager, Access};
use actix_security::http::security::web::RequestMatcherAuthorizer;
fn create_authorizer() -> RequestMatcherAuthorizer {
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
// Admin section requires ADMIN role
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
// API requires authentication
.add_matcher("/api/.*", Access::new().authenticated())
// Everything else is public
}
Step 3: Create Your Handlers
use actix_web::{get, post, web, HttpResponse, Responder};
use actix_security::{secured, pre_authorize, permit_all};
use actix_security::http::security::AuthenticatedUser;
// ============= Public Endpoints =============
#[permit_all]
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome to My App!")
}
#[permit_all]
#[get("/health")]
async fn health() -> impl Responder {
HttpResponse::Ok().body("OK")
}
// ============= User Endpoints =============
#[secured("USER")]
#[get("/profile")]
async fn get_profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!(
"Profile for: {}\nRoles: {:?}\nAuthorities: {:?}",
user.get_username(),
user.get_roles(),
user.get_authorities()
))
}
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:read')")]
#[get("/posts")]
async fn list_posts(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Posts for {}", user.get_username()))
}
#[pre_authorize(authority = "posts:write")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body(format!("Post created by {}", user.get_username()))
}
// ============= Admin Endpoints =============
#[secured("ADMIN")]
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Admin Dashboard - Welcome {}!", user.get_username()))
}
#[pre_authorize("hasRole('ADMIN') AND hasAuthority('users:write')")]
#[post("/admin/users")]
async fn create_user(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body("User created")
}
Step 4: Wire It All Together
use actix_web::{App, HttpServer};
use actix_security::http::security::middleware::SecurityTransform;
use actix_security::http::security::SecurityHeaders;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("🚀 Starting secured server at http://127.0.0.1:8080");
let encoder = Argon2PasswordEncoder::new();
HttpServer::new(move || {
let enc = encoder.clone();
App::new()
// Add security headers
.wrap(SecurityHeaders::default())
// Add authentication & authorization
.wrap(
SecurityTransform::new()
.config_authenticator(move || create_authenticator(enc.clone()))
.config_authorizer(create_authorizer)
)
// Register routes
.service(index)
.service(health)
.service(get_profile)
.service(list_posts)
.service(create_post)
.service(admin_dashboard)
.service(create_user)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Step 5: Test Your Application
# Start the server
cargo run
Test Public Endpoints
curl http://127.0.0.1:8080/
# Output: Welcome to My App!
curl http://127.0.0.1:8080/health
# Output: OK
Test User Endpoints
# Guest can't access profile
curl -u guest:guest123 http://127.0.0.1:8080/profile
# Output: 403 Forbidden
# User can access profile
curl -u user:user123 http://127.0.0.1:8080/profile
# Output: Profile for: user...
# User can read posts
curl -u user:user123 http://127.0.0.1:8080/posts
# Output: Posts for user
# User can't create posts (no posts:write authority)
curl -X POST -u user:user123 http://127.0.0.1:8080/posts
# Output: 403 Forbidden
Test Admin Endpoints
# Admin can access everything
curl -u admin:admin123 http://127.0.0.1:8080/admin/dashboard
# Output: Admin Dashboard - Welcome admin!
curl -X POST -u admin:admin123 http://127.0.0.1:8080/posts
# Output: Post created by admin
curl -X POST -u admin:admin123 http://127.0.0.1:8080/admin/users
# Output: User created
# Regular user can't access admin
curl -u user:user123 http://127.0.0.1:8080/admin/dashboard
# Output: 403 Forbidden
Complete Source Code
See the full working example in the test crate.
Next Steps
- Learn about different Authentication methods
- Explore Authorization patterns
- Master Security Expressions
- Add Security Headers
Authentication
Authentication is the process of verifying who a user is. Actix Security provides a flexible authentication system inspired by Spring Security.
Core Concepts
The Authenticator Trait
All authentication is handled through the Authenticator trait:
pub trait Authenticator: Clone + Send + Sync + 'static {
/// Authenticate a request and return the user if successful.
fn get_user(&self, req: &ServiceRequest) -> Option<User>;
}
Implement this trait to create custom authentication mechanisms.
The User Model
A User represents an authenticated identity:
pub struct User {
pub username: String,
pub password: String, // Encoded password
pub roles: HashSet<String>,
pub authorities: HashSet<String>,
}
AuthenticatedUser Extractor
In your handlers, use AuthenticatedUser to access the current user:
use actix_security::http::security::AuthenticatedUser;
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Hello, {}!", user.get_username()))
}
Built-in Authenticators
MemoryAuthenticator
An in-memory user store, perfect for development and testing:
use actix_security::http::security::{
AuthenticationManager, Argon2PasswordEncoder, PasswordEncoder, User
};
let encoder = Argon2PasswordEncoder::new();
let authenticator = AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("secret"))
.roles(&["ADMIN".into()])
)
.with_user(
User::with_encoded_password("user", encoder.encode("password"))
.roles(&["USER".into()])
);
JwtAuthenticator
Stateless JWT-based authentication for REST APIs (requires jwt feature):
use actix_security::http::security::jwt::{JwtAuthenticator, JwtConfig};
let config = JwtConfig::new("your-256-bit-secret-key-minimum!")
.issuer("my-app")
.audience("my-api")
.expiration_hours(24);
let authenticator = JwtAuthenticator::new(config);
SessionAuthenticator
Server-side session-based authentication (requires session feature):
use actix_security::http::security::session::{SessionAuthenticator, SessionConfig};
let config = SessionConfig::new()
.user_key("authenticated_user")
.authenticated_key("is_authenticated");
let authenticator = SessionAuthenticator::new(config);
OAuth2Client
OAuth2/OIDC authentication for social login (requires oauth2 feature):
use actix_security::http::security::oauth2::{OAuth2Config, OAuth2Provider, OAuth2Client};
let config = OAuth2Config::new("client-id", "client-secret", "redirect-uri")
.provider(OAuth2Provider::Google);
let client = OAuth2Client::new(config).await?;
// Generate authorization URL
let (auth_url, state, pkce_verifier, nonce) = client.authorization_url();
Authentication Flow
Request → SecurityTransform → Authenticator.authenticate()
↓
┌───────────────┐
│ User found? │
└───────┬───────┘
│
┌───────────────┴───────────────┐
↓ ↓
[Yes: User] [No: None]
↓ ↓
Continue to Authorizer 401 Unauthorized
or redirect to login
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
AuthenticationManager | Authenticator trait |
UserDetailsService | Authenticator::get_user() |
UserDetails | User |
Authentication | AuthenticatedUser |
InMemoryUserDetailsManager | MemoryAuthenticator |
JwtDecoder | JwtAuthenticator |
SessionRegistry | SessionAuthenticator |
ClientRegistrationRepository | OAuth2ClientRepository |
OAuth2User | OAuth2User |
PasswordEncoder | PasswordEncoder trait |
Sections
- In-Memory Authentication - Quick setup with
MemoryAuthenticator - Password Encoding - Secure password storage
- HTTP Basic - HTTP Basic authentication
- JWT Authentication - Stateless token-based authentication for APIs
- Session Authentication - Server-side session management
- OAuth2 / OIDC - Social login and enterprise SSO
- Custom Authenticators - Build your own
In-Memory Authentication
The MemoryAuthenticator stores users in memory. It's ideal for:
- Development and testing
- Small applications with static user lists
- Prototyping
Note: For production applications with many users, implement a custom authenticator backed by a database.
Basic Usage
use actix_security::http::security::{
AuthenticationManager, Argon2PasswordEncoder, PasswordEncoder, User
};
let encoder = Argon2PasswordEncoder::new();
let authenticator = AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into(), "USER".into()])
)
.with_user(
User::with_encoded_password("user", encoder.encode("password"))
.roles(&["USER".into()])
);
Creating Users
With Roles Only
User::with_encoded_password("username", encoder.encode("password"))
.roles(&["ROLE1".into(), "ROLE2".into()])
With Authorities Only
User::with_encoded_password("username", encoder.encode("password"))
.authorities(&["read".into(), "write".into()])
With Both Roles and Authorities
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into()])
.authorities(&[
"users:read".into(),
"users:write".into(),
"posts:read".into(),
"posts:write".into(),
])
Integration with SecurityTransform
use actix_security::http::security::middleware::SecurityTransform;
let encoder = Argon2PasswordEncoder::new();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || {
let enc = encoder.clone();
AuthenticationManager::in_memory_authentication()
.password_encoder(enc.clone())
.with_user(
User::with_encoded_password("admin", enc.encode("admin"))
.roles(&["ADMIN".into()])
)
})
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.http_basic()
})
)
Spring Security Comparison
Spring Security:
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("ADMIN", "USER")
.build();
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
Actix Security:
let encoder = Argon2PasswordEncoder::new();
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into(), "USER".into()])
)
.with_user(
User::with_encoded_password("user", encoder.encode("password"))
.roles(&["USER".into()])
)
Thread Safety
MemoryAuthenticator is thread-safe and can be shared across multiple worker threads. It implements Clone + Send + Sync.
Limitations
- Users are stored in memory (lost on restart)
- Not suitable for large user bases
- No dynamic user management at runtime
For production use cases, consider implementing a Custom Authenticator backed by a database.
Password Encoding
Never store passwords in plain text. Actix Security provides secure password encoding out of the box.
The PasswordEncoder Trait
pub trait PasswordEncoder: Clone + Send + Sync + 'static {
/// Encode a raw password.
fn encode(&self, raw_password: &str) -> String;
/// Check if a raw password matches an encoded password.
fn matches(&self, raw_password: &str, encoded_password: &str) -> bool;
}
Available Encoders
Argon2PasswordEncoder (Recommended)
Uses the Argon2id algorithm, winner of the Password Hashing Competition.
use actix_security::http::security::{Argon2PasswordEncoder, PasswordEncoder};
let encoder = Argon2PasswordEncoder::new();
// Encode a password
let encoded = encoder.encode("my_secure_password");
// Output: $argon2id$v=19$m=19456,t=2,p=1$...
// Verify a password
assert!(encoder.matches("my_secure_password", &encoded));
assert!(!encoder.matches("wrong_password", &encoded));
Features:
- Memory-hard (resistant to GPU attacks)
- Configurable parameters
- Recommended by OWASP
Requires the
argon2feature flag (enabled by default).
NoOpPasswordEncoder
Stores passwords in plain text. Only use for testing!
use actix_security::http::security::{NoOpPasswordEncoder, PasswordEncoder};
let encoder = NoOpPasswordEncoder::new();
let encoded = encoder.encode("password");
assert_eq!(encoded, "password"); // No encoding!
assert!(encoder.matches("password", "password"));
⚠️ Warning: Never use
NoOpPasswordEncoderin production!
DelegatingPasswordEncoder
Supports multiple encoding formats, useful for password migration.
use actix_security::http::security::{
DelegatingPasswordEncoder, Argon2PasswordEncoder, NoOpPasswordEncoder, PasswordEncoder
};
let encoder = DelegatingPasswordEncoder::new()
.with_encoder("argon2", Box::new(Argon2PasswordEncoder::new()))
.with_encoder("noop", Box::new(NoOpPasswordEncoder::new()))
.default_encoder("argon2");
// New passwords use argon2
let encoded = encoder.encode("password");
// Output: {argon2}$argon2id$v=19$...
// Can still verify old noop passwords
assert!(encoder.matches("old_password", "{noop}old_password"));
// Can verify new argon2 passwords
assert!(encoder.matches("password", &encoded));
Best Practices
1. Use Argon2 for New Applications
let encoder = Argon2PasswordEncoder::new();
2. Migrate Existing Passwords
Use DelegatingPasswordEncoder to gradually migrate:
let encoder = DelegatingPasswordEncoder::new()
.with_encoder("argon2", Box::new(Argon2PasswordEncoder::new()))
.with_encoder("bcrypt", Box::new(BcryptEncoder::new())) // Your old encoder
.default_encoder("argon2"); // New passwords use argon2
3. Never Log or Display Passwords
// Bad - logs the password
log::info!("User {} with password {}", username, password);
// Good - only log non-sensitive data
log::info!("User {} logged in", username);
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
PasswordEncoder | PasswordEncoder trait |
BCryptPasswordEncoder | Argon2PasswordEncoder |
NoOpPasswordEncoder | NoOpPasswordEncoder |
DelegatingPasswordEncoder | DelegatingPasswordEncoder |
Spring Security:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
Actix Security:
let encoder = Argon2PasswordEncoder::new();
Implementing Custom Encoders
use actix_security::http::security::PasswordEncoder;
#[derive(Clone)]
pub struct MyCustomEncoder;
impl PasswordEncoder for MyCustomEncoder {
fn encode(&self, raw_password: &str) -> String {
// Your encoding logic
format!("{{custom}}{}", some_hash_function(raw_password))
}
fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
// Your verification logic
let expected = self.encode(raw_password);
constant_time_eq(expected.as_bytes(), encoded_password.as_bytes())
}
}
Security Considerations
- Use strong algorithms - Argon2id is currently recommended
- Use constant-time comparison - Prevents timing attacks
- Salt passwords - Argon2 does this automatically
- Tune parameters - Adjust memory/time cost based on your hardware
- Re-hash on login - Upgrade old hashes when users log in
HTTP Basic Authentication
HTTP Basic Authentication sends credentials in the Authorization header.
How It Works
Authorization: Basic base64(username:password)
Example:
Authorization: Basic YWRtaW46YWRtaW4= // admin:admin
Enabling HTTP Basic
Configure your authorizer to use HTTP Basic:
use actix_security::http::security::AuthorizationManager;
let authorizer = AuthorizationManager::request_matcher()
.http_basic() // Enable HTTP Basic
.login_url("/login");
Full Example
use actix_web::{get, App, HttpServer, HttpResponse, Responder};
use actix_security::secured;
use actix_security::http::security::{
AuthenticatedUser, AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User,
};
use actix_security::http::security::middleware::SecurityTransform;
#[secured("USER")]
#[get("/api/data")]
async fn get_data(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Data for {}", user.get_username()))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let encoder = Argon2PasswordEncoder::new();
HttpServer::new(move || {
let enc = encoder.clone();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || {
AuthenticationManager::in_memory_authentication()
.password_encoder(enc.clone())
.with_user(
User::with_encoded_password("api_user", enc.encode("api_secret"))
.roles(&["USER".into()])
)
})
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.http_basic() // Enable HTTP Basic
})
)
.service(get_data)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Testing with cURL
# Using -u flag (automatic base64 encoding)
curl -u api_user:api_secret http://127.0.0.1:8080/api/data
# Manual header
curl -H "Authorization: Basic YXBpX3VzZXI6YXBpX3NlY3JldA==" http://127.0.0.1:8080/api/data
Testing in Rust
use actix_web::test;
use base64::prelude::*;
fn basic_auth(username: &str, password: &str) -> String {
let credentials = format!("{}:{}", username, password);
format!("Basic {}", BASE64_STANDARD.encode(credentials))
}
#[actix_web::test]
async fn test_http_basic() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/api/data")
.insert_header(("Authorization", basic_auth("api_user", "api_secret")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
401 Response
When authentication fails, the server returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted"
Security Considerations
Use HTTPS
HTTP Basic sends credentials in base64 encoding (not encryption). Always use HTTPS in production.
// In production, bind to HTTPS
HttpServer::new(|| App::new())
.bind_openssl("0.0.0.0:443", ssl_builder)?
.run()
.await
Consider Token-Based Auth
For APIs, consider using:
- JWT tokens
- API keys
- OAuth2
HTTP Basic is simple but has limitations:
- Credentials sent with every request
- No built-in expiration
- Harder to revoke access
Spring Security Comparison
Spring Security:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
}
Actix Security:
SecurityTransform::new()
.config_authenticator(|| /* ... */)
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.http_basic()
})
Feature Flag
HTTP Basic authentication requires the http-basic feature flag:
[dependencies]
actix-security = { version = "0.2", features = ["http-basic"] }
This feature is enabled by default.
JWT Authentication
JSON Web Token (JWT) authentication for stateless API security.
Overview
JWT authentication is ideal for:
- REST APIs
- Microservices
- Single Page Applications (SPAs)
- Mobile applications
Feature Flag
Enable JWT support in your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["jwt"] }
Quick Start
use actix_security::http::security::jwt::{JwtAuthenticator, JwtConfig};
use actix_security::http::security::middleware::SecurityTransform;
// Configure JWT
let config = JwtConfig::new("your-256-bit-secret-key-minimum!")
.issuer("my-app")
.audience("my-api")
.expiration_hours(24);
let authenticator = JwtAuthenticator::new(config);
// Use with SecurityTransform
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || authenticator.clone())
.config_authorizer(|| AuthorizationManager::request_matcher())
)
Configuration Options
let config = JwtConfig::new("your-secret-key")
// Algorithm (default: HS256)
.algorithm(Algorithm::HS512)
// Issuer claim validation
.issuer("my-app")
// Audience claim validation
.audience("my-api")
// Token expiration
.expiration_secs(3600) // or
.expiration_hours(1) // or
.expiration_days(7)
// Validation leeway (for clock skew)
.leeway_secs(60)
// Custom header (default: "Authorization")
.header_name("X-Auth-Token")
// Custom prefix (default: "Bearer ")
.header_prefix("Token ");
Token Generation
Generate Token for User
use actix_security::http::security::jwt::{JwtAuthenticator, JwtConfig};
use actix_security::http::security::User;
let config = JwtConfig::new("secret").expiration_hours(24);
let authenticator = JwtAuthenticator::new(config);
// Create user
let user = User::new("john".to_string(), "".to_string())
.roles(&["USER".into()])
.authorities(&["posts:read".into()]);
// Generate token
let token = authenticator.generate_token(&user)?;
// Returns: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Generate Token with Custom Claims
use actix_security::http::security::jwt::Claims;
let claims = Claims::new("john", 3600)
.issuer("my-app")
.audience("my-api")
.roles(vec!["USER".to_string()])
.authorities(vec!["posts:read".to_string()])
.custom(serde_json::json!({
"tenant_id": "acme",
"department": "engineering"
}));
let token = authenticator.generate_token_with_claims(&claims)?;
Token Validation
// Validate and get claims
let token_data = authenticator.validate_token(&token)?;
let claims = token_data.claims;
println!("Username: {}", claims.sub);
println!("Roles: {:?}", claims.roles);
println!("Expires: {}", claims.exp);
Token Service (Access + Refresh Tokens)
use actix_security::http::security::jwt::JwtTokenService;
let service = JwtTokenService::new(config)
.refresh_expiration_days(7);
// Generate access token (short-lived, includes roles)
let access_token = service.generate_token(&user)?;
// Generate refresh token (long-lived, minimal claims)
let refresh_token = service.generate_refresh_token(&user)?;
Complete Example
Login Endpoint
use actix_web::{post, web, HttpResponse, Responder};
use actix_security::http::security::jwt::{JwtAuthenticator, JwtConfig};
use actix_security::http::security::{
AuthenticationManager, Argon2PasswordEncoder, PasswordEncoder, User
};
#[derive(Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Serialize)]
struct LoginResponse {
access_token: String,
token_type: String,
expires_in: u64,
}
#[post("/auth/login")]
async fn login(
form: web::Json<LoginRequest>,
authenticator: web::Data<JwtAuthenticator>,
users: web::Data<MemoryAuthenticator>,
) -> impl Responder {
// Validate credentials
let user = match users.find_user(&form.username) {
Some(u) if encoder.matches(&form.password, u.get_password()) => u,
_ => return HttpResponse::Unauthorized().body("Invalid credentials"),
};
// Generate token
match authenticator.generate_token(&user) {
Ok(token) => HttpResponse::Ok().json(LoginResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: 3600,
}),
Err(_) => HttpResponse::InternalServerError().body("Token generation failed"),
}
}
Protected Endpoint
use actix_security::secured;
use actix_security::http::security::AuthenticatedUser;
#[secured("USER")]
#[get("/api/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"username": user.get_username(),
"roles": user.get_roles(),
}))
}
Client Usage
# Login
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "john", "password": "secret"}'
# Response: {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600}
# Access protected resource
curl http://localhost:8080/api/profile \
-H "Authorization: Bearer eyJ..."
JWT Claims Structure
{
"sub": "username",
"iss": "my-app",
"aud": "my-api",
"exp": 1735689600,
"iat": 1735686000,
"roles": ["USER", "ADMIN"],
"authorities": ["posts:read", "posts:write"]
}
Algorithms
Supported algorithms:
| Algorithm | Description |
|---|---|
HS256 | HMAC-SHA256 (default) |
HS384 | HMAC-SHA384 |
HS512 | HMAC-SHA512 |
RS256 | RSA-SHA256 |
RS384 | RSA-SHA384 |
RS512 | RSA-SHA512 |
ES256 | ECDSA-SHA256 |
ES384 | ECDSA-SHA384 |
Security Best Practices
- Use strong secrets - At least 256 bits (32 characters) for HMAC
- Set appropriate expiration - Short-lived tokens (15 min - 1 hour)
- Use HTTPS - Always transmit tokens over HTTPS
- Validate claims - Always validate issuer and audience
- Store tokens securely - Never store in localStorage for web apps
- Implement token refresh - Use refresh tokens for long sessions
Spring Security Comparison
Spring Security:
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withSecretKey(secretKey).build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
Actix Security:
let config = JwtConfig::new("secret-key")
.issuer("my-app")
.expiration_hours(1);
let authenticator = JwtAuthenticator::new(config);
SecurityTransform::new()
.config_authenticator(move || authenticator.clone())
Session Authentication
Traditional session-based authentication using cookies.
Overview
Session authentication is ideal for:
- Traditional web applications
- Server-rendered pages
- Applications requiring logout/session invalidation
Feature Flag
Enable session support in your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["session"] }
actix-session = { version = "0.10", features = ["cookie-session"] }
Quick Start
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_session::config::CookieContentSecurity;
use actix_web::cookie::Key;
use actix_security::http::security::session::{SessionAuthenticator, SessionConfig};
use actix_security::http::security::middleware::SecurityTransform;
// Generate a secure key (in production, load from environment)
let secret_key = Key::generate();
// Session middleware
let session_middleware = SessionMiddleware::builder(
CookieSessionStore::default(),
secret_key.clone()
)
.cookie_secure(true) // HTTPS only in production
.cookie_content_security(CookieContentSecurity::Private)
.build();
// Session authenticator
let session_config = SessionConfig::new();
let authenticator = SessionAuthenticator::new(session_config.clone());
App::new()
.wrap(session_middleware)
.wrap(
SecurityTransform::new()
.config_authenticator(move || authenticator.clone())
.config_authorizer(|| AuthorizationManager::request_matcher())
)
.app_data(web::Data::new(session_config))
Configuration
let config = SessionConfig::new()
// Custom session key for user data (default: "security_user")
.user_key("my_user")
// Custom session key for auth flag (default: "security_authenticated")
.authenticated_key("my_auth");
Login/Logout
Login Handler
use actix_session::Session;
use actix_security::http::security::session::{SessionAuthenticator, SessionConfig};
use actix_security::http::security::User;
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
#[post("/login")]
async fn login(
session: Session,
form: web::Form<LoginForm>,
config: web::Data<SessionConfig>,
users: web::Data<MemoryAuthenticator>,
encoder: web::Data<Argon2PasswordEncoder>,
) -> impl Responder {
// Find user
let user = match users.find_user(&form.username) {
Some(u) => u,
None => return HttpResponse::Unauthorized().body("Invalid credentials"),
};
// Verify password
if !encoder.matches(&form.password, user.get_password()) {
return HttpResponse::Unauthorized().body("Invalid credentials");
}
// Store user in session
match SessionAuthenticator::login(&session, &user, &config) {
Ok(_) => HttpResponse::Ok().body(format!("Welcome, {}!", user.get_username())),
Err(e) => HttpResponse::InternalServerError().body(format!("Login failed: {}", e)),
}
}
Logout Handler
#[post("/logout")]
async fn logout(session: Session, config: web::Data<SessionConfig>) -> impl Responder {
SessionAuthenticator::logout(&session, &config);
HttpResponse::Ok().body("Logged out")
}
// Or clear entire session
#[post("/logout/all")]
async fn logout_all(session: Session) -> impl Responder {
SessionAuthenticator::clear_session(&session);
HttpResponse::Ok().body("Session cleared")
}
Session Utilities
Check Authentication
#[get("/status")]
async fn auth_status(session: Session, config: web::Data<SessionConfig>) -> impl Responder {
if SessionAuthenticator::is_authenticated(&session, &config) {
let user = SessionAuthenticator::get_session_user(&session, &config);
HttpResponse::Ok().json(serde_json::json!({
"authenticated": true,
"username": user.map(|u| u.get_username().to_string())
}))
} else {
HttpResponse::Ok().json(serde_json::json!({
"authenticated": false
}))
}
}
Complete Example
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use actix_session::{Session, SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::Key;
use actix_security::secured;
use actix_security::http::security::{
AuthenticatedUser, AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User,
};
use actix_security::http::security::session::{SessionAuthenticator, SessionConfig};
use actix_security::http::security::middleware::SecurityTransform;
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome! Please login at /login")
}
#[get("/login")]
async fn login_page() -> impl Responder {
HttpResponse::Ok()
.content_type("text/html")
.body(r#"
<form method="post" action="/login">
<input name="username" placeholder="Username">
<input name="password" type="password" placeholder="Password">
<button type="submit">Login</button>
</form>
"#)
}
#[post("/login")]
async fn do_login(
session: Session,
form: web::Form<LoginForm>,
config: web::Data<SessionConfig>,
) -> impl Responder {
// In real app, validate against database
if form.username == "admin" && form.password == "admin" {
let user = User::new("admin".to_string(), "".to_string())
.roles(&["ADMIN".into(), "USER".into()]);
SessionAuthenticator::login(&session, &user, &config).unwrap();
HttpResponse::Found()
.insert_header(("Location", "/dashboard"))
.finish()
} else {
HttpResponse::Unauthorized().body("Invalid credentials")
}
}
#[post("/logout")]
async fn logout(session: Session, config: web::Data<SessionConfig>) -> impl Responder {
SessionAuthenticator::logout(&session, &config);
HttpResponse::Found()
.insert_header(("Location", "/"))
.finish()
}
#[secured("USER")]
#[get("/dashboard")]
async fn dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Welcome to dashboard, {}!", user.get_username()))
}
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Admin panel for {}", user.get_username()))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let secret_key = Key::generate();
let session_config = SessionConfig::new();
HttpServer::new(move || {
let config = session_config.clone();
let authenticator = SessionAuthenticator::new(config.clone());
App::new()
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.build()
)
.wrap(
SecurityTransform::new()
.config_authenticator(move || authenticator.clone())
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.login_url("/login")
})
)
.app_data(web::Data::new(config))
.service(index)
.service(login_page)
.service(do_login)
.service(logout)
.service(dashboard)
.service(admin)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Session Storage Options
Cookie Session (Default)
use actix_session::storage::CookieSessionStore;
SessionMiddleware::new(CookieSessionStore::default(), secret_key)
Redis Session
[dependencies]
actix-session = { version = "0.10", features = ["redis-session"] }
use actix_session::storage::RedisSessionStore;
let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379").await?;
SessionMiddleware::new(redis_store, secret_key)
Security Best Practices
- Use secure cookies - Set
cookie_secure(true)in production - Use HTTP-only cookies - Prevents JavaScript access
- Set appropriate expiration - Balance security and UX
- Regenerate session on login - Prevent session fixation
- Use HTTPS - Always use HTTPS in production
- Implement CSRF protection - For form submissions
Spring Security Comparison
Spring Security:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard"))
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/"));
return http.build();
}
}
Actix Security:
// Session middleware handles cookie management
SessionMiddleware::new(CookieSessionStore::default(), secret_key)
// Login/logout handled in handlers
SessionAuthenticator::login(&session, &user, &config)?;
SessionAuthenticator::logout(&session, &config);
OAuth2 / OpenID Connect Authentication
OAuth2 and OpenID Connect (OIDC) authentication for social login and enterprise SSO.
Overview
OAuth2/OIDC authentication is ideal for:
- Social login (Google, GitHub, Facebook, etc.)
- Enterprise SSO (Okta, Auth0, Keycloak, Azure AD)
- Single Sign-On across multiple applications
- Delegated authentication
Feature Flag
Enable OAuth2 support in your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["oauth2"] }
Quick Start
Google OAuth2
use actix_security::http::security::oauth2::{
OAuth2Config, OAuth2Provider, OAuth2Client
};
// Configure Google OAuth2
let config = OAuth2Config::new(
std::env::var("GOOGLE_CLIENT_ID").unwrap(),
std::env::var("GOOGLE_CLIENT_SECRET").unwrap(),
"http://localhost:8080/oauth2/callback/google"
)
.provider(OAuth2Provider::Google);
// Create client (async - performs OIDC discovery)
let client = OAuth2Client::new(config).await?;
// Generate authorization URL
let (auth_url, state, pkce_verifier, nonce) = client.authorization_url();
// Redirect user to auth_url...
// Store state, pkce_verifier, and nonce in session for callback verification
GitHub OAuth2
let config = OAuth2Config::new(
std::env::var("GITHUB_CLIENT_ID").unwrap(),
std::env::var("GITHUB_CLIENT_SECRET").unwrap(),
"http://localhost:8080/oauth2/callback/github"
)
.provider(OAuth2Provider::GitHub);
// GitHub doesn't support OIDC, so no discovery needed
let client = OAuth2Client::new_basic(config)?;
Supported Providers
| Provider | OIDC Support | PKCE Support |
|---|---|---|
| Yes | Yes | |
| GitHub | No | No |
| Microsoft | Yes | Yes |
| No | No | |
| Apple | Yes | Yes |
| Okta | Yes | Yes |
| Auth0 | Yes | Yes |
| Keycloak | Yes | Yes |
Configuration Options
let config = OAuth2Config::new("client-id", "client-secret", "redirect-uri")
// Use a pre-configured provider
.provider(OAuth2Provider::Google)
// Or configure custom endpoints
.authorization_uri("https://auth.example.com/authorize")
.token_uri("https://auth.example.com/token")
.userinfo_uri("https://auth.example.com/userinfo")
// OIDC issuer for auto-discovery
.issuer_uri("https://auth.example.com")
// Scopes
.scopes(vec!["openid", "email", "profile"])
.add_scope("custom_scope")
// PKCE (enabled by default for supported providers)
.use_pkce(true)
// Username attribute extraction
.username_attribute("email") // Use email as username
// Custom authorization parameters
.authorization_param("prompt", "consent");
Authorization Code Flow
Step 1: Generate Authorization URL
use actix_web::{get, web, HttpResponse};
use actix_session::Session;
#[get("/oauth2/authorize/{provider}")]
async fn authorize(
provider: web::Path<String>,
session: Session,
clients: web::Data<OAuth2ClientRepository>,
) -> HttpResponse {
let client = clients.get_client(&provider).unwrap();
// Generate authorization URL with PKCE and nonce
let (auth_url, state, pkce_verifier, nonce) = client.authorization_url();
// Store state in session for CSRF protection
session.insert("oauth2_state", state.secret()).unwrap();
// Store PKCE verifier for token exchange
if let Some(verifier) = pkce_verifier {
session.insert("oauth2_pkce", verifier.secret()).unwrap();
}
// Store nonce for OIDC token validation
if let Some(n) = nonce {
session.insert("oauth2_nonce", n.secret()).unwrap();
}
HttpResponse::Found()
.append_header(("Location", auth_url.to_string()))
.finish()
}
Step 2: Handle Callback
use oauth2::{CsrfToken, PkceCodeVerifier};
use openidconnect::Nonce;
#[derive(Deserialize)]
struct CallbackQuery {
code: String,
state: String,
}
#[get("/oauth2/callback/{provider}")]
async fn callback(
provider: web::Path<String>,
query: web::Query<CallbackQuery>,
session: Session,
clients: web::Data<OAuth2ClientRepository>,
) -> HttpResponse {
let client = clients.get_client(&provider).unwrap();
// Verify state (CSRF protection)
let stored_state: String = session.get("oauth2_state").unwrap().unwrap();
if query.state != stored_state {
return HttpResponse::BadRequest().body("Invalid state");
}
// Retrieve PKCE verifier
let pkce_verifier = session
.get::<String>("oauth2_pkce")
.unwrap()
.map(|s| PkceCodeVerifier::new(s));
// Retrieve nonce for OIDC
let nonce = session
.get::<String>("oauth2_nonce")
.unwrap()
.map(|s| Nonce::new(s));
// Exchange code for tokens
let (oauth2_user, oidc_user) = client
.exchange_code(&query.code, pkce_verifier, nonce.as_ref())
.await
.map_err(|e| HttpResponse::InternalServerError().body(e.to_string()))?;
// Convert to authenticated user
let user = oauth2_user.to_user();
// Store user in session
session.insert("user", serde_json::to_string(&oauth2_user).unwrap()).unwrap();
HttpResponse::Found()
.append_header(("Location", "/"))
.finish()
}
OAuth2User
The OAuth2User contains information retrieved from the OAuth2 provider:
pub struct OAuth2User {
pub sub: String, // Unique identifier
pub name: Option<String>,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub picture: Option<String>,
pub locale: Option<String>,
pub attributes: HashMap<String, Value>, // Provider-specific
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub expires_at: Option<i64>,
pub provider: String,
}
// Get username (prefers email, falls back to sub)
let username = oauth2_user.username();
// Convert to security User
let user = oauth2_user.to_user();
// User has role "USER" and authority "OAUTH2_USER_GOOGLE"
OidcUser
For OIDC providers, you also get ID token claims:
pub struct OidcUser {
pub oauth2_user: OAuth2User,
pub id_token_claims: Option<IdTokenClaims>,
pub id_token: Option<String>, // Raw JWT
}
pub struct IdTokenClaims {
pub iss: String, // Issuer
pub sub: String, // Subject
pub aud: Vec<String>, // Audience
pub exp: i64, // Expiration
pub iat: i64, // Issued at
pub auth_time: Option<i64>,
pub nonce: Option<String>,
pub at_hash: Option<String>,
}
Multiple Providers
Use OAuth2ClientRepository to manage multiple providers:
use actix_security::http::security::oauth2::OAuth2ClientRepository;
// Build repository from configs
let configs = vec![
OAuth2Config::new(google_id, google_secret, google_redirect)
.provider(OAuth2Provider::Google),
OAuth2Config::new(github_id, github_secret, github_redirect)
.provider(OAuth2Provider::GitHub),
];
let repository = OAuth2ClientRepository::from_configs(configs).await?;
// Use in Actix Web
App::new()
.app_data(web::Data::new(repository))
.service(authorize)
.service(callback)
Custom Provider
Configure a custom OAuth2/OIDC provider:
let config = OAuth2Config::new("client-id", "secret", "redirect-uri")
.registration_id("custom")
.authorization_uri("https://custom.example.com/oauth/authorize")
.token_uri("https://custom.example.com/oauth/token")
.userinfo_uri("https://custom.example.com/oauth/userinfo")
// For OIDC with discovery:
.issuer_uri("https://custom.example.com")
.scopes(vec!["openid", "email", "profile"]);
Complete Example
use actix_web::{get, web, App, HttpServer, HttpResponse};
use actix_session::{Session, SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::Key;
use actix_security::http::security::oauth2::{
OAuth2Config, OAuth2Provider, OAuth2Client, OAuth2ClientRepository
};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Configure OAuth2 providers
let google_config = OAuth2Config::new(
std::env::var("GOOGLE_CLIENT_ID").unwrap(),
std::env::var("GOOGLE_CLIENT_SECRET").unwrap(),
"http://localhost:8080/oauth2/callback/google"
)
.provider(OAuth2Provider::Google);
let repository = OAuth2ClientRepository::from_configs(vec![google_config])
.await
.expect("Failed to create OAuth2 repository");
let secret_key = Key::generate();
HttpServer::new(move || {
App::new()
.wrap(SessionMiddleware::new(
CookieSessionStore::default(),
secret_key.clone()
))
.app_data(web::Data::new(repository.clone()))
.service(login_page)
.service(authorize)
.service(callback)
.service(profile)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
#[get("/login")]
async fn login_page() -> HttpResponse {
HttpResponse::Ok().body(r#"
<a href="/oauth2/authorize/google">Login with Google</a>
"#)
}
#[get("/profile")]
async fn profile(session: Session) -> HttpResponse {
if let Some(user_json) = session.get::<String>("user").unwrap() {
HttpResponse::Ok().body(format!("Logged in as: {}", user_json))
} else {
HttpResponse::Found()
.append_header(("Location", "/login"))
.finish()
}
}
Security Best Practices
- Always validate state - Prevents CSRF attacks
- Use PKCE - Prevents authorization code interception
- Validate nonce for OIDC - Prevents replay attacks
- Use HTTPS - Always in production
- Validate redirect URIs - Prevent open redirects
- Store tokens securely - Use encrypted sessions
- Handle token expiration - Implement refresh token flow
Spring Security Comparison
Spring Security:
@Configuration
public class OAuth2Config {
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(
CommonOAuth2Provider.GOOGLE.getBuilder("google")
.clientId("client-id")
.clientSecret("client-secret")
.build()
);
}
}
Actix Security:
let config = OAuth2Config::new("client-id", "client-secret", "redirect-uri")
.provider(OAuth2Provider::Google);
let repository = OAuth2ClientRepository::from_configs(vec![config]).await?;
Custom Authenticators
Create custom authenticators for database-backed user stores, OAuth, JWT, and more.
Implementing the Authenticator Trait
use actix_security::http::security::config::Authenticator;
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
#[derive(Clone)]
pub struct DatabaseAuthenticator {
pool: sqlx::PgPool, // Your database connection pool
}
impl Authenticator for DatabaseAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// 1. Extract credentials from request
let auth_header = req.headers().get("Authorization")?;
let (username, password) = parse_basic_auth(auth_header)?;
// 2. Look up user in database
// Note: This is sync, consider using block_on or async authenticator
let user_record = self.find_user(&username)?;
// 3. Verify password
if !self.verify_password(&password, &user_record.password_hash) {
return None;
}
// 4. Build and return User
Some(User {
username: user_record.username,
password: user_record.password_hash,
roles: user_record.roles.into_iter().collect(),
authorities: user_record.authorities.into_iter().collect(),
})
}
}
Example: JWT Authentication
use actix_security::http::security::config::Authenticator;
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
use jsonwebtoken::{decode, DecodingKey, Validation};
#[derive(Clone)]
pub struct JwtAuthenticator {
secret: String,
}
#[derive(Debug, Deserialize)]
struct Claims {
sub: String, // username
roles: Vec<String>,
authorities: Vec<String>,
exp: usize,
}
impl Authenticator for JwtAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Extract Bearer token
let auth_header = req.headers().get("Authorization")?.to_str().ok()?;
let token = auth_header.strip_prefix("Bearer ")?;
// Decode and validate JWT
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&Validation::default(),
).ok()?;
let claims = token_data.claims;
// Build User from claims
Some(User {
username: claims.sub,
password: String::new(), // Not needed for JWT
roles: claims.roles.into_iter().collect(),
authorities: claims.authorities.into_iter().collect(),
})
}
}
Example: API Key Authentication
use actix_security::http::security::config::Authenticator;
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
use std::collections::HashMap;
#[derive(Clone)]
pub struct ApiKeyAuthenticator {
api_keys: HashMap<String, User>, // API key -> User
}
impl ApiKeyAuthenticator {
pub fn new() -> Self {
let mut api_keys = HashMap::new();
// Register API keys
api_keys.insert(
"sk_live_abc123".to_string(),
User::new("service_a".to_string(), String::new())
.roles(&["SERVICE".into()])
.authorities(&["api:read".into(), "api:write".into()]),
);
Self { api_keys }
}
}
impl Authenticator for ApiKeyAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Check X-API-Key header
let api_key = req.headers()
.get("X-API-Key")?
.to_str()
.ok()?;
self.api_keys.get(api_key).cloned()
}
}
Example: Session-Based Authentication
use actix_security::http::security::config::Authenticator;
use actix_security::http::security::User;
use actix_session::SessionExt;
use actix_web::dev::ServiceRequest;
#[derive(Clone)]
pub struct SessionAuthenticator {
user_service: UserService, // Your user service
}
impl Authenticator for SessionAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Get session
let session = req.get_session();
// Get user ID from session
let user_id: i64 = session.get("user_id").ok()??;
// Load user from database
self.user_service.find_by_id(user_id)
}
}
Combining Multiple Authenticators
#[derive(Clone)]
pub struct CompositeAuthenticator {
authenticators: Vec<Box<dyn Authenticator>>,
}
impl Authenticator for CompositeAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Try each authenticator in order
for auth in &self.authenticators {
if let Some(user) = auth.authenticate(req) {
return Some(user);
}
}
None
}
}
// Usage
let authenticator = CompositeAuthenticator {
authenticators: vec![
Box::new(JwtAuthenticator::new()),
Box::new(ApiKeyAuthenticator::new()),
Box::new(BasicAuthenticator::new()),
],
};
Using with SecurityTransform
use actix_security::http::security::middleware::SecurityTransform;
let jwt_auth = JwtAuthenticator {
secret: "your-secret-key".to_string(),
};
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || jwt_auth.clone())
.config_authorizer(|| {
AuthorizationManager::request_matcher()
// No http_basic() needed for JWT
})
)
Best Practices
1. Handle Errors Gracefully
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Return None on any error - don't panic
let header = req.headers().get("Authorization")?;
let header_str = header.to_str().ok()?; // Use ok()? for Result
// ...
}
2. Use Constant-Time Comparison
use subtle::ConstantTimeEq;
fn verify_api_key(provided: &str, expected: &str) -> bool {
provided.as_bytes().ct_eq(expected.as_bytes()).into()
}
3. Log Security Events
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
let result = self.do_authenticate(req);
match &result {
Some(user) => log::info!("User {} authenticated", user.username),
None => log::warn!("Authentication failed for request to {}", req.path()),
}
result
}
4. Rate Limit Authentication
Consider rate limiting authentication attempts to prevent brute force attacks.
Spring Security Comparison
Spring Security:
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// Your authentication logic
return new UsernamePasswordAuthenticationToken(
username, password, authorities);
}
}
Actix Security:
impl Authenticator for CustomAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Your authentication logic
Some(User { /* ... */ })
}
}
Authorization
Authorization determines what an authenticated user can do. Actix Security provides two complementary approaches:
- URL-Based Authorization - Configure access rules for URL patterns
- Method Security - Protect individual handlers with attribute macros
Core Concepts
The Authorizer Trait
pub trait Authorizer: Clone + Send + Sync + 'static {
/// Check if the user can access the requested resource.
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult;
}
pub enum AuthorizationResult {
Granted, // Access allowed
Denied, // 403 Forbidden
LoginRequired, // 401 Unauthorized / redirect to login
}
Roles vs Authorities
Both are permission types, but serve different purposes:
| Concept | Purpose | Example |
|---|---|---|
| Roles | Coarse-grained access | ADMIN, USER, GUEST |
| Authorities | Fine-grained permissions | users:read, posts:write |
See Roles vs Authorities for detailed guidance.
URL-Based Authorization
Configure access rules by URL pattern:
use actix_security::http::security::{AuthorizationManager, Access};
let authorizer = AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
// Admin section
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
// API requires authentication
.add_matcher("/api/.*", Access::new().authenticated())
// User section
.add_matcher("/user/.*", Access::new().roles(vec!["USER", "ADMIN"]))
// Everything else is public
Method Security
Protect individual handlers with macros:
use actix_security::{secured, pre_authorize};
// Simple role check
#[secured("ADMIN")]
#[get("/admin/users")]
async fn list_users(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Expression-based
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder { /* ... */ }
Authorization Flow
Request → Authenticator → Authorizer
↓
┌─────────────────────┐
│ URL Pattern Match? │
└─────────┬───────────┘
│
┌──────────────────┼──────────────────┐
↓ ↓ ↓
[Matched] [No Match] [Public]
↓ ↓ ↓
Check roles/auth Continue to Allow request
↓ handler
┌──────┴──────┐
↓ ↓
[Granted] [Denied]
↓ ↓
Handler 403 Forbidden
↓
Method security
(if applicable)
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
authorizeHttpRequests() | RequestMatcherAuthorizer |
hasRole("ADMIN") | .roles(vec!["ADMIN"]) |
hasAuthority("read") | .authorities(vec!["read"]) |
authenticated() | .authenticated() |
permitAll() | No matcher (default allow) |
denyAll() | Access::new().deny_all() |
@PreAuthorize | #[pre_authorize] |
@Secured | #[secured] |
Sections
- URL-Based Authorization - Configure access by URL pattern
- Method Security - Protect handlers with macros
- Roles vs Authorities - When to use each
- Custom Authorizers - Build your own
URL-Based Authorization
Configure access rules for URL patterns using RequestMatcherAuthorizer.
Basic Usage
use actix_security::http::security::{AuthorizationManager, Access};
let authorizer = AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authenticated())
.add_matcher("/user/.*", Access::new().roles(vec!["USER", "ADMIN"]));
URL Patterns
Patterns use regex syntax:
| Pattern | Matches |
|---|---|
/admin/.* | /admin/, /admin/users, /admin/settings/security |
/api/v[0-9]+/.* | /api/v1/users, /api/v2/posts |
/user/[^/]+/profile | /user/john/profile, /user/123/profile |
.*\\.json | Any URL ending in .json |
Access Rules
Role-Based Access
// Single role
Access::new().roles(vec!["ADMIN"])
// Multiple roles (OR logic - any role grants access)
Access::new().roles(vec!["ADMIN", "MANAGER", "SUPERVISOR"])
Authority-Based Access
// Single authority
Access::new().authorities(vec!["users:read"])
// Multiple authorities (OR logic)
Access::new().authorities(vec!["users:read", "users:write"])
Combined Rules
// Require role AND authority
Access::new()
.roles(vec!["USER"])
.authorities(vec!["premium:access"])
Authentication Only
// Any authenticated user
Access::new().authenticated()
Deny All
// Block all access (useful for deprecated endpoints)
Access::new().deny_all()
Pattern Order
Patterns are matched in the order they're added. First match wins.
AuthorizationManager::request_matcher()
.add_matcher("/admin/public/.*", Access::new().authenticated()) // First
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"])) // Second
With this configuration:
/admin/public/info→ Any authenticated user/admin/users→ Only ADMIN role
Complete Example
use actix_web::{get, web, App, HttpServer, HttpResponse, Responder};
use actix_security::http::security::{
AuthenticatedUser, AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User, Access,
};
use actix_security::http::security::middleware::SecurityTransform;
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Home - Public")
}
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Admin: {}", user.get_username()))
}
#[get("/api/users")]
async fn api_users(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().json(vec!["user1", "user2"])
}
#[get("/user/profile")]
async fn user_profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Profile: {}", user.get_username()))
}
fn create_authorizer() -> RequestMatcherAuthorizer {
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
// Public paths (no matcher = public)
// Admin section
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
// API requires authentication + specific authority
.add_matcher("/api/.*", Access::new().authorities(vec!["api:access"]))
// User section
.add_matcher("/user/.*", Access::new().roles(vec!["USER", "ADMIN"]))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let encoder = Argon2PasswordEncoder::new();
HttpServer::new(move || {
let enc = encoder.clone();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || {
AuthenticationManager::in_memory_authentication()
.password_encoder(enc.clone())
.with_user(
User::with_encoded_password("admin", enc.encode("admin"))
.roles(&["ADMIN".into()])
.authorities(&["api:access".into()])
)
.with_user(
User::with_encoded_password("user", enc.encode("user"))
.roles(&["USER".into()])
)
})
.config_authorizer(create_authorizer)
)
.service(index)
.service(admin_dashboard)
.service(api_users)
.service(user_profile)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Testing URL Authorization
# Public - no auth needed
curl http://127.0.0.1:8080/
# Output: Home - Public
# Admin section - requires ADMIN role
curl -u admin:admin http://127.0.0.1:8080/admin/dashboard
# Output: Admin: admin
curl -u user:user http://127.0.0.1:8080/admin/dashboard
# Output: 403 Forbidden
# API - requires api:access authority
curl -u admin:admin http://127.0.0.1:8080/api/users
# Output: ["user1","user2"]
curl -u user:user http://127.0.0.1:8080/api/users
# Output: 403 Forbidden (user doesn't have api:access)
# User section - requires USER or ADMIN role
curl -u user:user http://127.0.0.1:8080/user/profile
# Output: Profile: user
Spring Security Comparison
Spring Security:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").hasAuthority("api:access")
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().permitAll()
);
Actix Security:
AuthorizationManager::request_matcher()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authorities(vec!["api:access"]))
.add_matcher("/user/.*", Access::new().roles(vec!["USER", "ADMIN"]))
// No matcher = permit all
Best Practices
- Order patterns from specific to general
- Use method security for complex rules - URL patterns are best for simple role checks
- Don't over-complicate patterns - Keep regex simple and readable
- Document your security rules - Complex patterns can be hard to maintain
Method Security
Protect individual handlers with attribute macros for fine-grained access control.
Overview
Method security complements URL-based authorization by adding checks directly to handlers. This is useful when:
- Different endpoints at similar URLs need different permissions
- You want self-documenting security rules
- Complex authorization logic is needed
Available Macros
| Macro | Spring Equivalent | Use Case |
|---|---|---|
#[secured("ROLE")] | @Secured | Simple role check |
#[pre_authorize(...)] | @PreAuthorize | Expression-based access |
#[permit_all] | @PermitAll | Explicitly public |
#[deny_all] | @DenyAll | Block all access |
#[roles_allowed("ROLE")] | @RolesAllowed | Java EE style |
@secured
Simple role-based access control:
use actix_security::secured;
use actix_security::http::security::AuthenticatedUser;
// Single role
#[secured("ADMIN")]
#[get("/admin/users")]
async fn list_users(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("User list")
}
// Multiple roles (OR logic - any role grants access)
#[secured("ADMIN", "MANAGER")]
#[get("/reports")]
async fn view_reports(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Reports")
}
@pre_authorize
Expression-based access control with full expression language support:
use actix_security::pre_authorize;
// Role check
#[pre_authorize(role = "ADMIN")]
#[get("/admin")]
async fn admin_only(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Authority check
#[pre_authorize(authority = "users:write")]
#[post("/users")]
async fn create_user(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Multiple authorities (OR logic)
#[pre_authorize(authorities = ["users:read", "users:write"])]
#[get("/users")]
async fn list_users(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Authenticated only
#[pre_authorize(authenticated)]
#[get("/profile")]
async fn get_profile(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Full expression
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder { /* ... */ }
See Security Expressions for full expression syntax.
@permit_all
Mark endpoints as explicitly public:
use actix_security::permit_all;
#[permit_all]
#[get("/health")]
async fn health_check() -> impl Responder {
HttpResponse::Ok().body("OK")
}
Note: The handler doesn't receive
AuthenticatedUsersince no auth is required.
@deny_all
Block all access (useful for deprecated endpoints):
use actix_security::deny_all;
#[deny_all]
#[get("/deprecated/endpoint")]
async fn deprecated_endpoint(_user: AuthenticatedUser) -> impl Responder {
// This code is never reached
HttpResponse::Ok().body("Never executed")
}
@roles_allowed
Java EE style role checking (alias for @secured):
use actix_security::roles_allowed;
#[roles_allowed("ADMIN")]
#[get("/admin")]
async fn admin_panel(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin Panel")
}
#[roles_allowed("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Management")
}
Macro Placement
Important: Security macros must be placed before the route macro:
// ✓ Correct
#[secured("ADMIN")]
#[get("/admin")]
async fn admin() -> impl Responder { /* ... */ }
// ✗ Wrong - won't work
#[get("/admin")]
#[secured("ADMIN")]
async fn admin() -> impl Responder { /* ... */ }
Combining with URL Authorization
Method security and URL authorization work together:
// URL authorization
let authorizer = AuthorizationManager::request_matcher()
.add_matcher("/api/.*", Access::new().authenticated());
// Method security adds additional checks
#[pre_authorize(authority = "posts:write")]
#[post("/api/posts")] // URL requires authentication, method requires authority
async fn create_post(user: AuthenticatedUser) -> impl Responder { /* ... */ }
Error Handling
When access is denied, the macro returns 403 Forbidden:
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
// If user doesn't have ADMIN role, this code never runs
// A 403 Forbidden response is returned instead
HttpResponse::Ok().body("Admin")
}
The actual implementation wraps your handler:
// Your code:
#[secured("ADMIN")]
async fn admin(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Expands to (simplified):
async fn admin(user: AuthenticatedUser) -> Result<impl Responder, AuthError> {
if !user.has_role("ADMIN") {
return Err(AuthError::Forbidden);
}
Ok(/* your original code */)
}
Complete Example
use actix_web::{get, post, delete, App, HttpServer, HttpResponse, Responder};
use actix_security::{secured, pre_authorize, permit_all, deny_all};
use actix_security::http::security::AuthenticatedUser;
// Public endpoints
#[permit_all]
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome!")
}
// Authenticated users
#[pre_authorize(authenticated)]
#[get("/dashboard")]
async fn dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Hello, {}!", user.get_username()))
}
// Role-based
#[secured("USER")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Profile")
}
// Authority-based
#[pre_authorize(authority = "posts:write")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body("Post created")
}
// Complex expression
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND hasAuthority('posts:delete'))")]
#[delete("/posts/{id}")]
async fn delete_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Post deleted")
}
// Deprecated
#[deny_all]
#[get("/old-api")]
async fn old_api(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Never reached")
}
Spring Security Comparison
Spring Security:
@Secured("ROLE_ADMIN")
@GetMapping("/admin")
public String admin() { return "admin"; }
@PreAuthorize("hasRole('USER') and hasAuthority('posts:write')")
@PostMapping("/posts")
public String createPost() { return "created"; }
@PermitAll
@GetMapping("/public")
public String publicEndpoint() { return "public"; }
@DenyAll
@GetMapping("/deprecated")
public String deprecated() { return "never"; }
@RolesAllowed({"ADMIN", "MANAGER"})
@GetMapping("/management")
public String management() { return "management"; }
Actix Security:
#[secured("ADMIN")]
#[get("/admin")]
async fn admin() -> impl Responder { /* ... */ }
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post() -> impl Responder { /* ... */ }
#[permit_all]
#[get("/public")]
async fn public_endpoint() -> impl Responder { /* ... */ }
#[deny_all]
#[get("/deprecated")]
async fn deprecated() -> impl Responder { /* ... */ }
#[roles_allowed("ADMIN", "MANAGER")]
#[get("/management")]
async fn management() -> impl Responder { /* ... */ }
Roles vs Authorities
Understanding when to use roles versus authorities is key to designing a good security model.
Quick Comparison
| Aspect | Roles | Authorities |
|---|---|---|
| Granularity | Coarse | Fine |
| Purpose | User categories | Specific permissions |
| Examples | ADMIN, USER, GUEST | users:read, posts:write |
| Use when | Grouping users | Controlling actions |
Roles
Roles represent what type of user someone is.
Characteristics
- Coarse-grained categories
- Usually few per application (3-10)
- Often hierarchical (ADMIN > MANAGER > USER)
- Represent job functions or user types
Examples
User::with_encoded_password("john", encoded_password)
.roles(&["USER".into()])
User::with_encoded_password("jane", encoded_password)
.roles(&["ADMIN".into(), "USER".into()])
User::with_encoded_password("service", encoded_password)
.roles(&["SERVICE".into()])
Usage
// URL-based
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
// Method-based
#[secured("ADMIN")]
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder { /* ... */ }
Authorities
Authorities represent what actions a user can perform.
Characteristics
- Fine-grained permissions
- Many per application (10-100+)
- Usually flat (no hierarchy)
- Represent specific operations
Naming Convention
Use resource:action format:
users:read - Read user data
users:write - Create/update users
users:delete - Delete users
posts:read - Read posts
posts:write - Create/update posts
posts:publish - Publish posts
admin:access - Access admin area
reports:view - View reports
reports:export - Export reports
Examples
User::with_encoded_password("content_manager", encoded_password)
.roles(&["USER".into()])
.authorities(&[
"posts:read".into(),
"posts:write".into(),
"posts:publish".into(),
])
User::with_encoded_password("analyst", encoded_password)
.roles(&["USER".into()])
.authorities(&[
"reports:view".into(),
"reports:export".into(),
])
Usage
// URL-based
.add_matcher("/api/reports/.*", Access::new().authorities(vec!["reports:view"]))
// Method-based
#[pre_authorize(authority = "posts:publish")]
#[post("/posts/{id}/publish")]
async fn publish_post(user: AuthenticatedUser) -> impl Responder { /* ... */ }
When to Use Each
Use Roles When:
-
Controlling broad sections of your app
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"])) -
User type matters more than specific permission
#[secured("PREMIUM")] #[get("/premium-content")] async fn premium_content() -> impl Responder { /* ... */ } -
Simple applications with clear user categories
Use Authorities When:
-
Controlling specific operations
#[pre_authorize(authority = "users:delete")] #[delete("/users/{id}")] async fn delete_user() -> impl Responder { /* ... */ } -
Same role needs different capabilities
// Both are USERs, but with different permissions User::with_encoded_password("editor", pwd) .roles(&["USER".into()]) .authorities(&["posts:write".into(), "posts:publish".into()]) User::with_encoded_password("writer", pwd) .roles(&["USER".into()]) .authorities(&["posts:write".into()]) // Can write but not publish -
Building permission-based features
// In handler, check specific permissions if user.has_authority("reports:export") { // Show export button }
Combining Roles and Authorities
The most flexible approach uses both:
// Define users with roles AND authorities
User::with_encoded_password("admin", encoded_password)
.roles(&["ADMIN".into()])
.authorities(&[
"users:read".into(),
"users:write".into(),
"users:delete".into(),
"posts:read".into(),
"posts:write".into(),
"posts:delete".into(),
"reports:view".into(),
"reports:export".into(),
])
User::with_encoded_password("content_editor", encoded_password)
.roles(&["USER".into()])
.authorities(&[
"posts:read".into(),
"posts:write".into(),
])
// Use roles for broad access control
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
// Use authorities for specific operations
#[pre_authorize(authority = "posts:publish")]
#[post("/posts/{id}/publish")]
async fn publish_post() -> impl Responder { /* ... */ }
// Combine in expressions
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('posts:delete')")]
#[delete("/posts/{id}")]
async fn delete_post() -> impl Responder { /* ... */ }
OR Logic
Both roles and authorities use OR logic - user needs at least one matching role OR authority:
// User needs ADMIN OR MANAGER (not both)
Access::new().roles(vec!["ADMIN", "MANAGER"])
// User needs users:read OR users:write (not both)
Access::new().authorities(vec!["users:read", "users:write"])
For AND logic, use expressions:
#[pre_authorize("hasRole('USER') AND hasAuthority('premium')")]
async fn premium_feature() -> impl Responder { /* ... */ }
Spring Security Comparison
Spring Security:
// Roles (Spring adds ROLE_ prefix internally)
@Secured("ROLE_ADMIN")
@PreAuthorize("hasRole('ADMIN')")
// Authorities (no prefix)
@PreAuthorize("hasAuthority('users:read')")
Actix Security:
// Roles (no prefix magic)
#[secured("ADMIN")]
#[pre_authorize("hasRole('ADMIN')")]
// Authorities
#[pre_authorize("hasAuthority('users:read')")]
Note: Unlike Spring Security, Actix Security doesn't add any
ROLE_prefix. Roles are stored exactly as you define them.
Best Practices
-
Use consistent naming
- Roles: UPPERCASE (ADMIN, USER, MANAGER)
- Authorities: lowercase:action (users:read, posts:write)
-
Don't over-engineer
- Start with roles only
- Add authorities when you need finer control
-
Document your permission model
// Document what each authority means /// users:read - View user list and profiles /// users:write - Create and update users /// users:delete - Delete users (admin only) -
Consider a permission matrix
Role users:read users:write users:delete ADMIN ✓ ✓ ✓ MANAGER ✓ ✓ - USER ✓ - -
Custom Authorizers
Create custom authorizers for complex authorization logic, external policy engines, or domain-specific rules.
Implementing the Authorizer Trait
use actix_security::http::security::config::{Authorizer, AuthorizationResult};
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
#[derive(Clone)]
pub struct CustomAuthorizer {
// Your configuration
}
impl Authorizer for CustomAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Your authorization logic
match user {
Some(u) if self.check_access(u, req) => AuthorizationResult::Granted,
Some(_) => AuthorizationResult::Denied,
None => AuthorizationResult::LoginRequired,
}
}
}
Authorization Results
Return one of three results:
pub enum AuthorizationResult {
Granted, // Allow access
Denied, // 403 Forbidden
LoginRequired, // 401 Unauthorized or redirect to login
}
Example: Time-Based Access
use chrono::{Local, Timelike};
#[derive(Clone)]
pub struct BusinessHoursAuthorizer {
inner: RequestMatcherAuthorizer,
}
impl Authorizer for BusinessHoursAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// First, check standard authorization
let result = self.inner.authorize(user, req);
if result != AuthorizationResult::Granted {
return result;
}
// Then, check business hours for certain paths
if req.path().starts_with("/business/") {
let hour = Local::now().hour();
if hour < 9 || hour >= 17 {
log::warn!("Access denied outside business hours");
return AuthorizationResult::Denied;
}
}
AuthorizationResult::Granted
}
}
Example: IP-Based Access
use std::net::IpAddr;
#[derive(Clone)]
pub struct IpWhitelistAuthorizer {
inner: RequestMatcherAuthorizer,
admin_ips: Vec<IpAddr>,
}
impl Authorizer for IpWhitelistAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Check if admin path
if req.path().starts_with("/admin/") {
// Get client IP
let client_ip = req.peer_addr()
.map(|addr| addr.ip());
// Check whitelist
if let Some(ip) = client_ip {
if !self.admin_ips.contains(&ip) {
log::warn!("Admin access denied from IP: {}", ip);
return AuthorizationResult::Denied;
}
} else {
return AuthorizationResult::Denied;
}
}
self.inner.authorize(user, req)
}
}
Example: Resource Owner Check
#[derive(Clone)]
pub struct ResourceOwnerAuthorizer {
inner: RequestMatcherAuthorizer,
}
impl Authorizer for ResourceOwnerAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Check standard authorization first
let result = self.inner.authorize(user, req);
if result != AuthorizationResult::Granted {
return result;
}
// For user-specific paths, check ownership
// Path: /users/{user_id}/...
if let Some(user) = user {
if let Some(captures) = regex::Regex::new(r"/users/(\w+)/")
.unwrap()
.captures(req.path())
{
let path_user_id = &captures[1];
// Allow if admin OR owner
if !user.has_role("ADMIN") && user.username != path_user_id {
return AuthorizationResult::Denied;
}
}
}
AuthorizationResult::Granted
}
}
Example: External Policy Engine (OPA)
use reqwest::blocking::Client;
#[derive(Clone)]
pub struct OpaAuthorizer {
opa_url: String,
client: Client,
}
impl OpaAuthorizer {
pub fn new(opa_url: &str) -> Self {
Self {
opa_url: opa_url.to_string(),
client: Client::new(),
}
}
}
impl Authorizer for OpaAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
let input = serde_json::json!({
"input": {
"user": user.map(|u| &u.username),
"roles": user.map(|u| &u.roles).unwrap_or(&HashSet::new()),
"path": req.path(),
"method": req.method().as_str(),
}
});
match self.client
.post(&format!("{}/v1/data/authz/allow", self.opa_url))
.json(&input)
.send()
{
Ok(resp) => {
let result: serde_json::Value = resp.json().unwrap_or_default();
if result["result"].as_bool().unwrap_or(false) {
AuthorizationResult::Granted
} else if user.is_some() {
AuthorizationResult::Denied
} else {
AuthorizationResult::LoginRequired
}
}
Err(e) => {
log::error!("OPA request failed: {}", e);
AuthorizationResult::Denied // Fail closed
}
}
}
}
Composing Authorizers
#[derive(Clone)]
pub struct CompositeAuthorizer {
authorizers: Vec<Box<dyn Authorizer>>,
}
impl Authorizer for CompositeAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// All authorizers must grant access
for authorizer in &self.authorizers {
match authorizer.authorize(user, req) {
AuthorizationResult::Granted => continue,
result => return result,
}
}
AuthorizationResult::Granted
}
}
// Usage
let authorizer = CompositeAuthorizer {
authorizers: vec![
Box::new(RequestMatcherAuthorizer::new()),
Box::new(IpWhitelistAuthorizer::new()),
Box::new(BusinessHoursAuthorizer::new()),
],
};
Using with SecurityTransform
let custom_authorizer = CustomAuthorizer::new();
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(|| /* ... */)
.config_authorizer(move || custom_authorizer.clone())
)
Best Practices
1. Fail Closed
When in doubt, deny access:
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// If anything goes wrong, deny
match self.do_authorize(user, req) {
Ok(result) => result,
Err(e) => {
log::error!("Authorization error: {}", e);
AuthorizationResult::Denied
}
}
}
2. Log Security Decisions
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
let result = self.check_access(user, req);
match &result {
AuthorizationResult::Denied => {
log::warn!(
"Access denied: user={:?}, path={}, method={}",
user.map(|u| &u.username),
req.path(),
req.method()
);
}
_ => {}
}
result
}
3. Keep It Simple
Complex authorization logic should live in your business layer, not the authorizer:
// Good - simple authorizer, complex logic elsewhere
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
if self.policy_service.is_allowed(user, req.path(), req.method()) {
AuthorizationResult::Granted
} else {
AuthorizationResult::Denied
}
}
4. Test Thoroughly
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_admin_ip_whitelist() {
let authorizer = IpWhitelistAuthorizer::new(vec!["10.0.0.1".parse().unwrap()]);
// Test allowed IP
// Test denied IP
// Test missing IP
}
}
Spring Security Comparison
Spring Security:
@Component
public class CustomAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(
Supplier<Authentication> authentication,
RequestAuthorizationContext context
) {
// Your logic
return new AuthorizationDecision(allowed);
}
}
Actix Security:
impl Authorizer for CustomAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Your logic
if allowed {
AuthorizationResult::Granted
} else {
AuthorizationResult::Denied
}
}
}
Security Macros Overview
Actix Security provides attribute macros for declarative method-level security, inspired by Spring Security and Java EE annotations.
Available Macros
| Macro | Spring Equivalent | Java EE Equivalent | Description |
|---|---|---|---|
#[secured] | @Secured | - | Role-based access |
#[pre_authorize] | @PreAuthorize | - | Expression-based access |
#[permit_all] | @PermitAll | @PermitAll | Public access |
#[deny_all] | @DenyAll | @DenyAll | Block all access |
#[roles_allowed] | @Secured | @RolesAllowed | Java EE style roles |
Quick Reference
use actix_security::{secured, pre_authorize, permit_all, deny_all, roles_allowed};
use actix_security::http::security::AuthenticatedUser;
// Simple role check
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Multiple roles (OR)
#[secured("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Expression-based
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Authority check
#[pre_authorize(authority = "users:delete")]
#[delete("/users/{id}")]
async fn delete_user(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Authenticated only
#[pre_authorize(authenticated)]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Public endpoint
#[permit_all]
#[get("/health")]
async fn health() -> impl Responder { /* ... */ }
// Disabled endpoint
#[deny_all]
#[get("/deprecated")]
async fn deprecated(_user: AuthenticatedUser) -> impl Responder { /* ... */ }
// Java EE style
#[roles_allowed("ADMIN", "USER")]
#[get("/app")]
async fn app(user: AuthenticatedUser) -> impl Responder { /* ... */ }
How They Work
Security macros wrap your handler with authorization checks at compile time. When a check fails, a 403 Forbidden response is returned before your handler code executes.
Before (your code)
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin")
}
After (macro expansion, simplified)
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> Result<impl Responder, AuthError> {
// Injected security check
if !user.has_any_role(&["ADMIN"]) {
return Err(AuthError::Forbidden);
}
// Your original code
Ok(HttpResponse::Ok().body("Admin"))
}
Macro Placement
Important: Security macros must be placed before the route macro:
// ✓ Correct
#[secured("ADMIN")]
#[get("/admin")]
async fn admin() -> impl Responder { /* ... */ }
// ✗ Wrong - security check won't be applied
#[get("/admin")]
#[secured("ADMIN")]
async fn admin() -> impl Responder { /* ... */ }
Compile-Time Expression Parsing
For #[pre_authorize] with expressions, parsing and validation happens at compile time:
// This is validated at compile time
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('users:write')")]
#[get("/users")]
async fn users(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// This would cause a compile error
#[pre_authorize("invalid expression !!!")] // Compile error
#[get("/users")]
async fn users(user: AuthenticatedUser) -> impl Responder { /* ... */ }
Benefits:
- Zero runtime overhead - No expression parsing at request time
- Early error detection - Invalid expressions fail at compile time
- Type safety - Rust's type system ensures correctness
Sections
- @secured - Simple role-based security
- @pre_authorize - Expression-based security
- @permit_all - Public endpoints
- @deny_all - Blocked endpoints
- @roles_allowed - Java EE style
@secured
Simple role-based method security. Use when you need to check one or more roles.
Syntax
#[secured("ROLE")] // Single role
#[secured("ROLE1", "ROLE2")] // Multiple roles (OR logic)
Basic Usage
use actix_web::{get, HttpResponse, Responder};
use actix_security::secured;
use actix_security::http::security::AuthenticatedUser;
// Single role
#[secured("ADMIN")]
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Welcome Admin: {}", user.get_username()))
}
// Multiple roles - user needs ANY of the specified roles
#[secured("ADMIN", "MANAGER", "SUPERVISOR")]
#[get("/reports")]
async fn view_reports(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Reports")
}
OR Logic
When multiple roles are specified, the user needs at least one matching role:
#[secured("ADMIN", "MANAGER")]
This grants access if the user has ADMIN OR MANAGER role.
For AND logic, use #[pre_authorize]:
#[pre_authorize("hasRole('ADMIN') AND hasRole('MANAGER')")]
Handler Requirements
The handler must have an AuthenticatedUser parameter:
// ✓ Correct
#[secured("USER")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder { /* ... */ }
// ✗ Wrong - missing AuthenticatedUser
#[secured("USER")]
#[get("/profile")]
async fn profile() -> impl Responder { /* ... */ }
The AuthenticatedUser provides access to user information:
#[secured("USER")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
let username = user.get_username();
let roles = user.get_roles();
let authorities = user.get_authorities();
HttpResponse::Ok().body(format!(
"User: {}\nRoles: {:?}\nAuthorities: {:?}",
username, roles, authorities
))
}
Examples
Admin-Only Endpoint
#[secured("ADMIN")]
#[get("/admin/users")]
async fn list_all_users(user: AuthenticatedUser) -> impl Responder {
// Only ADMIN can access
HttpResponse::Ok().json(get_all_users())
}
Premium Content
#[secured("PREMIUM", "ADMIN")]
#[get("/premium/content")]
async fn premium_content(user: AuthenticatedUser) -> impl Responder {
// PREMIUM users and ADMINs can access
HttpResponse::Ok().body("Exclusive content")
}
Service Account
#[secured("SERVICE")]
#[post("/internal/sync")]
async fn internal_sync(user: AuthenticatedUser) -> impl Responder {
// Only SERVICE accounts can call this
HttpResponse::Ok().body("Synced")
}
Error Response
When access is denied, a 403 Forbidden response is returned:
HTTP/1.1 403 Forbidden
Content-Length: 0
How It Works
The macro expands to:
// Input
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin")
}
// Expansion (simplified)
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> Result<impl Responder, AuthError> {
if !user.has_any_role(&["ADMIN".to_string()]) {
return Err(AuthError::Forbidden);
}
Ok(HttpResponse::Ok().body("Admin"))
}
Spring Security Comparison
Spring Security:
@Secured("ROLE_ADMIN")
@GetMapping("/admin")
public String admin() {
return "admin";
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
@GetMapping("/management")
public String management() {
return "management";
}
Actix Security:
#[secured("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("admin")
}
#[secured("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("management")
}
Note: Unlike Spring Security, Actix Security doesn't add a
ROLE_prefix. Roles are used exactly as specified.
When to Use
Use #[secured] when:
- You need simple role checks
- OR logic is sufficient
- You don't need expressions
Use #[pre_authorize] instead when:
- You need authority checks
- You need AND logic
- You need complex expressions
@pre_authorize
Expression-based method security. The most powerful and flexible security macro.
Syntax Options
// Simple checks
#[pre_authorize(authenticated)] // Any authenticated user
#[pre_authorize(role = "ADMIN")] // Single role
#[pre_authorize(authority = "users:write")] // Single authority
#[pre_authorize(authorities = ["a", "b"])] // Multiple authorities (OR)
// Expression syntax
#[pre_authorize("hasRole('ADMIN')")]
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER') OR hasAuthority('reports:view')")]
Simple Checks
Authenticated Only
#[pre_authorize(authenticated)]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Hello, {}!", user.get_username()))
}
Single Role
#[pre_authorize(role = "ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin panel")
}
Single Authority
#[pre_authorize(authority = "users:delete")]
#[delete("/users/{id}")]
async fn delete_user(user: AuthenticatedUser, path: web::Path<i64>) -> impl Responder {
HttpResponse::Ok().body(format!("Deleted user {}", path.into_inner()))
}
Multiple Authorities (OR)
#[pre_authorize(authorities = ["users:read", "users:write"])]
#[get("/users")]
async fn list_users(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().json(vec!["user1", "user2"])
}
Expression Syntax
For complex authorization rules, use the expression syntax:
#[pre_authorize("expression")]
Available Functions
| Function | Description |
|---|---|
hasRole('ROLE') | User has the specified role |
hasAnyRole('R1', 'R2') | User has any of the roles |
hasAuthority('auth') | User has the authority |
hasAnyAuthority('a1', 'a2') | User has any of the authorities |
isAuthenticated() | User is authenticated |
permitAll() | Always allow |
denyAll() | Always deny |
Operators
| Operator | Description |
|---|---|
AND | Both conditions must be true |
OR | Either condition can be true |
NOT | Negates the condition |
( ) | Groups expressions |
Expression Examples
Basic Expressions
// Role check
#[pre_authorize("hasRole('ADMIN')")]
// Authority check
#[pre_authorize("hasAuthority('posts:write')")]
// Any of multiple roles
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER', 'SUPERVISOR')")]
// Any of multiple authorities
#[pre_authorize("hasAnyAuthority('posts:read', 'posts:write')")]
Combining with AND
// Must have role AND authority
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body("Post created")
}
Combining with OR
// Either admin role OR specific authority
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('users:write')")]
#[put("/users/{id}")]
async fn update_user(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("User updated")
}
Using NOT
// Anyone except guests
#[pre_authorize("NOT hasRole('GUEST')")]
#[get("/premium")]
async fn premium(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Premium content")
}
Complex Expressions
// Admin OR (User with write permission)
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND hasAuthority('posts:write'))")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body("Post created")
}
// Multiple conditions
#[pre_authorize("(hasAnyRole('ADMIN', 'MANAGER')) AND hasAuthority('reports:export')")]
#[get("/reports/export")]
async fn export_reports(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Exported")
}
Permit All / Deny All
#[pre_authorize("permitAll()")]
#[get("/public")]
async fn public_info(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Public")
}
#[pre_authorize("denyAll()")]
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Never reached")
}
Compile-Time Validation
Expressions are parsed and validated at compile time:
// ✓ Valid - compiles successfully
#[pre_authorize("hasRole('ADMIN') AND hasAuthority('users:write')")]
// ✗ Invalid - compile error: unexpected token
#[pre_authorize("hasRole('ADMIN') && hasAuthority('users:write')")]
// ✗ Invalid - compile error: unmatched parenthesis
#[pre_authorize("hasRole('ADMIN'")]
// ✗ Invalid - compile error: unknown function
#[pre_authorize("hasPermission('admin')")]
Error Response
When access is denied:
HTTP/1.1 403 Forbidden
Content-Length: 0
Spring Security Comparison
Spring Security:
@PreAuthorize("hasRole('ADMIN')")
public void adminOnly() {}
@PreAuthorize("hasRole('USER') and hasAuthority('posts:write')")
public void createPost() {}
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER') or hasAuthority('reports:view')")
public void viewReports() {}
@PreAuthorize("isAuthenticated()")
public void authenticated() {}
Actix Security:
#[pre_authorize("hasRole('ADMIN')")]
async fn admin_only() {}
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")]
async fn create_post() {}
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER') OR hasAuthority('reports:view')")]
async fn view_reports() {}
#[pre_authorize("isAuthenticated()")]
async fn authenticated() {}
Key differences:
- Use
AND/ORinstead ofand/or(case-insensitive but uppercase is conventional) - Use single quotes for strings:
'ADMIN'not"ADMIN"
When to Use
Use #[pre_authorize] when:
- You need authority checks
- You need AND/OR/NOT logic
- You need complex expressions
- You want Spring Security-like syntax
Use #[secured] instead when:
- You only need simple role checks
- OR logic is sufficient
@permit_all
Marks an endpoint as publicly accessible. No authentication required.
Syntax
#[permit_all]
Usage
use actix_web::{get, HttpResponse, Responder};
use actix_security::permit_all;
#[permit_all]
#[get("/health")]
async fn health_check() -> impl Responder {
HttpResponse::Ok().body("OK")
}
#[permit_all]
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome!")
}
No AuthenticatedUser Required
Unlike other security macros, #[permit_all] handlers don't need an AuthenticatedUser parameter since no authentication is required:
// ✓ Correct - no AuthenticatedUser needed
#[permit_all]
#[get("/public")]
async fn public_endpoint() -> impl Responder {
HttpResponse::Ok().body("Public content")
}
// ✓ Also valid - AuthenticatedUser is optional
#[permit_all]
#[get("/info")]
async fn info(user: Option<AuthenticatedUser>) -> impl Responder {
match user {
Some(u) => HttpResponse::Ok().body(format!("Hello, {}!", u.get_username())),
None => HttpResponse::Ok().body("Hello, guest!"),
}
}
Common Use Cases
Health Checks
#[permit_all]
#[get("/health")]
async fn health() -> impl Responder {
HttpResponse::Ok().body("OK")
}
#[permit_all]
#[get("/ready")]
async fn readiness() -> impl Responder {
// Check database, etc.
HttpResponse::Ok().body("Ready")
}
Public API Endpoints
#[permit_all]
#[get("/api/public/version")]
async fn api_version() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"version": "1.0.0"
}))
}
Landing Pages
#[permit_all]
#[get("/")]
async fn home() -> impl Responder {
HttpResponse::Ok().body("Welcome to our app!")
}
#[permit_all]
#[get("/about")]
async fn about() -> impl Responder {
HttpResponse::Ok().body("About us")
}
Login/Registration
#[permit_all]
#[get("/login")]
async fn login_page() -> impl Responder {
HttpResponse::Ok().body("Login form")
}
#[permit_all]
#[post("/login")]
async fn do_login(form: web::Form<LoginForm>) -> impl Responder {
// Process login
HttpResponse::Ok().body("Logged in")
}
#[permit_all]
#[post("/register")]
async fn register(form: web::Form<RegisterForm>) -> impl Responder {
// Process registration
HttpResponse::Created().body("Registered")
}
Important Note
#[permit_all] marks the handler as public, but URL-based authorization still applies. If your URL matcher requires authentication for the path, users will still need to authenticate.
To make an endpoint truly public, ensure your URL matcher doesn't require authentication for that path:
// URL authorization
let authorizer = AuthorizationManager::request_matcher()
.add_matcher("/api/private/.*", Access::new().authenticated())
// /api/public/.* has no matcher, so it's public by default
;
// Handler authorization
#[permit_all] // Explicitly marks handler as public
#[get("/api/public/info")]
async fn public_info() -> impl Responder {
HttpResponse::Ok().body("Public info")
}
How It Works
The macro simply passes through your function unchanged:
// Input
#[permit_all]
#[get("/health")]
async fn health() -> impl Responder {
HttpResponse::Ok().body("OK")
}
// Output (unchanged)
#[get("/health")]
async fn health() -> impl Responder {
HttpResponse::Ok().body("OK")
}
The macro serves as documentation and ensures consistency with other security annotations.
Spring Security / Java EE Comparison
Spring Security / Java EE:
@PermitAll
@GetMapping("/public")
public String publicEndpoint() {
return "public";
}
Actix Security:
#[permit_all]
#[get("/public")]
async fn public_endpoint() -> impl Responder {
HttpResponse::Ok().body("public")
}
When to Use
Use #[permit_all] for:
- Health check endpoints
- Public API endpoints
- Login/registration pages
- Landing pages
- Any endpoint that should be accessible without authentication
Consider using URL-based authorization instead when you have many public endpoints under a common path prefix.
@deny_all
Blocks all access to an endpoint. Always returns 403 Forbidden.
Syntax
#[deny_all]
Usage
use actix_web::{get, HttpResponse, Responder};
use actix_security::deny_all;
use actix_security::http::security::AuthenticatedUser;
#[deny_all]
#[get("/disabled")]
async fn disabled_endpoint(_user: AuthenticatedUser) -> impl Responder {
// This code is never executed
HttpResponse::Ok().body("Never reached")
}
Common Use Cases
Temporarily Disable Endpoints
// Disable an endpoint for maintenance
#[deny_all]
#[post("/payments/process")]
async fn process_payment(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Processing")
}
Deprecate Endpoints
// Mark old API version as deprecated
#[deny_all]
#[get("/api/v1/users")]
async fn v1_users(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Old API")
}
Placeholder for Future Features
// Reserve endpoint for future implementation
#[deny_all]
#[get("/premium/advanced-analytics")]
async fn advanced_analytics(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Coming soon")
}
Security Lockdown
// Emergency lockdown of sensitive endpoints
#[deny_all]
#[delete("/admin/database")]
async fn delete_database(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Deleted")
}
AuthenticatedUser Parameter
The handler should have an AuthenticatedUser parameter (typically prefixed with _ since it's unused):
#[deny_all]
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Never reached")
}
The _user parameter is needed for type inference, even though the handler code is never executed.
Response
Always returns:
HTTP/1.1 403 Forbidden
Content-Length: 0
Regardless of:
- User authentication status
- User roles or authorities
- Request method or body
How It Works
The macro replaces your handler with one that immediately returns Forbidden:
// Input
#[deny_all]
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Never reached")
}
// Expansion (simplified)
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> Result<impl Responder, AuthError> {
return Err(AuthError::Forbidden);
// Unreachable - kept for type inference
#[allow(unreachable_code)]
Ok(HttpResponse::Ok().body("Never reached"))
}
Spring Security / Java EE Comparison
Spring Security / Java EE:
@DenyAll
@GetMapping("/disabled")
public String disabled() {
return "never reached";
}
Actix Security:
#[deny_all]
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("never reached")
}
When to Use
Use #[deny_all] when:
- Temporarily disabling an endpoint
- Deprecating old API versions
- Reserving endpoints for future use
- Emergency security lockdown
Consider removing the endpoint entirely if it's permanently disabled.
Alternatives
Return Custom Error
If you want to return a custom message:
#[get("/deprecated")]
async fn deprecated() -> impl Responder {
HttpResponse::Gone().body("This endpoint has been deprecated. Use /api/v2 instead.")
}
Conditional Disable
If you want to conditionally disable:
#[get("/feature")]
async fn feature(user: AuthenticatedUser) -> impl Responder {
if !feature_flag_enabled("new_feature") {
return HttpResponse::ServiceUnavailable()
.body("Feature temporarily disabled");
}
HttpResponse::Ok().body("Feature content")
}
@roles_allowed
Java EE-style role-based security. Functionally equivalent to #[secured].
Syntax
#[roles_allowed("ROLE")] // Single role
#[roles_allowed("ROLE1", "ROLE2")] // Multiple roles (OR logic)
Usage
use actix_web::{get, HttpResponse, Responder};
use actix_security::roles_allowed;
use actix_security::http::security::AuthenticatedUser;
// Single role
#[roles_allowed("ADMIN")]
#[get("/admin")]
async fn admin_panel(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin Panel")
}
// Multiple roles
#[roles_allowed("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Management")
}
Java EE Equivalent
This macro follows the Java EE @RolesAllowed annotation:
Java EE:
@RolesAllowed("ADMIN")
@GET
@Path("/admin")
public String admin() {
return "admin";
}
@RolesAllowed({"ADMIN", "MANAGER"})
@GET
@Path("/management")
public String management() {
return "management";
}
Actix Security:
#[roles_allowed("ADMIN")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("admin")
}
#[roles_allowed("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("management")
}
Comparison with @secured
#[roles_allowed] and #[secured] are functionally identical. Choose based on your preferred naming convention:
| If you're coming from... | Use |
|---|---|
| Spring Security | #[secured] |
| Java EE / Jakarta EE | #[roles_allowed] |
// These are equivalent:
#[roles_allowed("ADMIN")]
#[get("/admin")]
async fn admin_v1(user: AuthenticatedUser) -> impl Responder { /* ... */ }
#[secured("ADMIN")]
#[get("/admin")]
async fn admin_v2(user: AuthenticatedUser) -> impl Responder { /* ... */ }
OR Logic
Like #[secured], multiple roles use OR logic:
#[roles_allowed("ADMIN", "MANAGER", "SUPERVISOR")]
#[get("/reports")]
async fn reports(user: AuthenticatedUser) -> impl Responder {
// Access granted if user has ADMIN OR MANAGER OR SUPERVISOR
HttpResponse::Ok().body("Reports")
}
For AND logic, use #[pre_authorize]:
#[pre_authorize("hasRole('ADMIN') AND hasRole('AUDITOR')")]
#[get("/audit")]
async fn audit(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Audit")
}
Examples
Admin Dashboard
#[roles_allowed("ADMIN")]
#[get("/admin/dashboard")]
async fn admin_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Welcome, Admin {}!", user.get_username()))
}
Multi-Tier Access
// Executive + Management access
#[roles_allowed("EXECUTIVE", "MANAGER")]
#[get("/reports/financial")]
async fn financial_reports(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Financial Reports")
}
// All staff access
#[roles_allowed("EXECUTIVE", "MANAGER", "EMPLOYEE")]
#[get("/reports/general")]
async fn general_reports(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("General Reports")
}
Service Account
#[roles_allowed("SERVICE", "ADMIN")]
#[post("/internal/sync")]
async fn internal_sync(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Synchronized")
}
How It Works
#[roles_allowed] delegates to #[secured] internally:
// Input
#[roles_allowed("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Management")
}
// Effectively same as
#[secured("ADMIN", "MANAGER")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Management")
}
// Which expands to (simplified)
#[get("/management")]
async fn management(user: AuthenticatedUser) -> Result<impl Responder, AuthError> {
if !user.has_any_role(&["ADMIN".to_string(), "MANAGER".to_string()]) {
return Err(AuthError::Forbidden);
}
Ok(HttpResponse::Ok().body("Management"))
}
When to Use
Use #[roles_allowed] when:
- You prefer Java EE naming conventions
- You're porting code from Java EE
- Your team is familiar with
@RolesAllowed
Use #[secured] instead when:
- You prefer Spring Security naming conventions
- Your team is familiar with
@Secured
Use #[pre_authorize] instead when:
- You need authority checks (not just roles)
- You need complex AND/OR/NOT logic
- You need expression-based security
Security Expression Language
Actix Security includes a powerful expression language inspired by Spring Security's SpEL (Spring Expression Language) for security.
Overview
Security expressions allow you to write complex authorization rules in a readable, declarative syntax:
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND hasAuthority('posts:write'))")]
Key Features
- Compile-time parsing - Expressions are validated at compile time
- Zero runtime overhead - Expressions are compiled to Rust code
- Extensible - Add custom functions via
ExpressionRoottrait - Familiar syntax - Similar to Spring Security SpEL
Expression Syntax
Functions
| Function | Description | Example |
|---|---|---|
hasRole('R') | User has role R | hasRole('ADMIN') |
hasAnyRole('R1', 'R2') | User has any of the roles | hasAnyRole('ADMIN', 'MANAGER') |
hasAuthority('A') | User has authority A | hasAuthority('users:read') |
hasAnyAuthority('A1', 'A2') | User has any of the authorities | hasAnyAuthority('read', 'write') |
isAuthenticated() | User is authenticated | isAuthenticated() |
permitAll() | Always true | permitAll() |
denyAll() | Always false | denyAll() |
Operators
| Operator | Description | Example |
|---|---|---|
AND | Both must be true | hasRole('A') AND hasRole('B') |
OR | Either can be true | hasRole('A') OR hasRole('B') |
NOT | Negation | NOT hasRole('GUEST') |
( ) | Grouping | (hasRole('A') OR hasRole('B')) AND hasAuthority('x') |
Examples
Basic Expressions
// Single role check
#[pre_authorize("hasRole('ADMIN')")]
// Single authority check
#[pre_authorize("hasAuthority('posts:write')")]
// Any of multiple roles
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER', 'SUPERVISOR')")]
// Any of multiple authorities
#[pre_authorize("hasAnyAuthority('read', 'write', 'delete')")]
// Authenticated user
#[pre_authorize("isAuthenticated()")]
Combining Conditions
// AND - both must be true
#[pre_authorize("hasRole('USER') AND hasAuthority('premium')")]
// OR - either can be true
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('users:manage')")]
// NOT - negation
#[pre_authorize("NOT hasRole('GUEST')")]
#[pre_authorize("isAuthenticated() AND NOT hasRole('SUSPENDED')")]
Complex Expressions
// Admin OR (User with write permission)
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND hasAuthority('posts:write'))")]
// Multiple groups
#[pre_authorize("(hasRole('ADMIN') OR hasRole('MANAGER')) AND hasAuthority('reports:view')")]
// Nested conditions
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND (hasAuthority('a') OR hasAuthority('b')))")]
Compile-Time Validation
Expressions are parsed and validated at compile time:
// ✓ Valid
#[pre_authorize("hasRole('ADMIN') AND hasAuthority('write')")]
// ✗ Compile error: Use 'AND' not '&&'
#[pre_authorize("hasRole('ADMIN') && hasAuthority('write')")]
// ✗ Compile error: Unknown function
#[pre_authorize("hasPermission('admin')")]
// ✗ Compile error: Syntax error
#[pre_authorize("hasRole('ADMIN'")]
How It Works
1. Parse at Compile Time
The expression is parsed into an AST:
hasRole('ADMIN') OR hasAuthority('write')
↓
Binary(OR)
/ \
hasRole hasAuthority
('ADMIN') ('write')
2. Generate Rust Code
The AST is compiled to Rust code:
// Expression: hasRole('ADMIN') OR hasAuthority('write')
// Generates:
user.has_role("ADMIN") || user.has_authority("write")
3. Execute at Runtime
The generated Rust code executes with zero parsing overhead.
Spring Security Comparison
Spring Security (Java):
@PreAuthorize("hasRole('ADMIN') or hasAuthority('users:write')")
public void updateUser() {}
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER') and hasAuthority('reports:view')")
public void viewReports() {}
@PreAuthorize("isAuthenticated() and !hasRole('GUEST')")
public void premiumContent() {}
Actix Security (Rust):
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('users:write')")]
async fn update_user() {}
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER') AND hasAuthority('reports:view')")]
async fn view_reports() {}
#[pre_authorize("isAuthenticated() AND NOT hasRole('GUEST')")]
async fn premium_content() {}
Key differences:
- Use
AND/OR/NOTinstead ofand/or/!(case-insensitive) - Use single quotes for strings:
'ADMIN'not"ADMIN"
Sections
- Built-in Functions - Detailed function reference
- Custom Expressions - Extend with your own functions
Built-in Expression Functions
Reference for all built-in security expression functions.
Role Functions
hasRole
Checks if the user has a specific role.
#[pre_authorize("hasRole('ADMIN')")]
Parameters:
role- The role name (string)
Returns: true if user has the role
Example:
#[pre_authorize("hasRole('ADMIN')")]
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Admin")
}
hasAnyRole
Checks if the user has any of the specified roles.
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER', 'SUPERVISOR')")]
Parameters:
roles- Variable number of role names
Returns: true if user has at least one of the roles
Example:
#[pre_authorize("hasAnyRole('ADMIN', 'MANAGER')")]
#[get("/management")]
async fn management(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Management")
}
Authority Functions
hasAuthority
Checks if the user has a specific authority.
#[pre_authorize("hasAuthority('users:write')")]
Parameters:
authority- The authority name (string)
Returns: true if user has the authority
Example:
#[pre_authorize("hasAuthority('posts:write')")]
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Created().body("Post created")
}
hasAnyAuthority
Checks if the user has any of the specified authorities.
#[pre_authorize("hasAnyAuthority('read', 'write', 'admin')")]
Parameters:
authorities- Variable number of authority names
Returns: true if user has at least one of the authorities
Example:
#[pre_authorize("hasAnyAuthority('posts:read', 'posts:write')")]
#[get("/posts")]
async fn list_posts(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Posts")
}
Authentication Functions
isAuthenticated
Checks if the user is authenticated.
#[pre_authorize("isAuthenticated()")]
Parameters: None
Returns: true if user is authenticated
Example:
#[pre_authorize("isAuthenticated()")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body(format!("Hello, {}!", user.get_username()))
}
Access Control Functions
permitAll
Always returns true. Allows all access.
#[pre_authorize("permitAll()")]
Parameters: None
Returns: Always true
Example:
#[pre_authorize("permitAll()")]
#[get("/public")]
async fn public_info(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Public")
}
Note: For public endpoints, consider using
#[permit_all]macro instead, which doesn't requireAuthenticatedUser.
denyAll
Always returns false. Denies all access.
#[pre_authorize("denyAll()")]
Parameters: None
Returns: Always false
Example:
#[pre_authorize("denyAll()")]
#[get("/disabled")]
async fn disabled(_user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Never reached")
}
Note: Consider using
#[deny_all]macro instead for cleaner syntax.
Function Reference Table
| Function | Parameters | Returns | Description |
|---|---|---|---|
hasRole(role) | 1 string | bool | User has role |
hasAnyRole(r1, r2, ...) | 1+ strings | bool | User has any role |
hasAuthority(auth) | 1 string | bool | User has authority |
hasAnyAuthority(a1, a2, ...) | 1+ strings | bool | User has any authority |
isAuthenticated() | none | bool | User is authenticated |
permitAll() | none | bool | Always true |
denyAll() | none | bool | Always false |
Combining Functions
With AND
Both conditions must be true:
#[pre_authorize("hasRole('USER') AND hasAuthority('premium')")]
With OR
Either condition can be true:
#[pre_authorize("hasRole('ADMIN') OR hasAuthority('users:manage')")]
With NOT
Negates a condition:
#[pre_authorize("NOT hasRole('GUEST')")]
#[pre_authorize("isAuthenticated() AND NOT hasRole('SUSPENDED')")]
With Grouping
Use parentheses for complex logic:
#[pre_authorize("(hasRole('ADMIN') OR hasRole('MANAGER')) AND hasAuthority('reports:view')")]
Case Sensitivity
- Operators are case-insensitive:
AND,and,Andall work - Function names are case-sensitive:
hasRoleworks,HasRoledoesn't - Role/Authority names are case-sensitive as stored in your user
String Quoting
Use single quotes for string arguments:
// ✓ Correct
#[pre_authorize("hasRole('ADMIN')")]
// ✗ Wrong - double quotes cause parsing issues
#[pre_authorize("hasRole(\"ADMIN\")")]
Custom Expressions
Extend the security expression language with your own functions.
The ExpressionRoot Trait
Custom expression functions are added by implementing ExpressionRoot:
use actix_security::http::security::expression::ExpressionRoot;
use actix_security::http::security::User;
pub trait ExpressionRoot: Send + Sync {
/// Evaluate a custom function.
///
/// Returns:
/// - `Some(true)` - Function matched and returned true
/// - `Some(false)` - Function matched and returned false
/// - `None` - Function not recognized, try default implementation
fn evaluate_function(
&self,
name: &str,
args: &[String],
user: Option<&User>,
) -> Option<bool>;
}
Creating a Custom ExpressionRoot
use actix_security::http::security::expression::ExpressionRoot;
use actix_security::http::security::User;
use std::collections::HashSet;
#[derive(Clone)]
pub struct CustomExpressionRoot {
premium_users: HashSet<String>,
beta_features: HashSet<String>,
}
impl ExpressionRoot for CustomExpressionRoot {
fn evaluate_function(
&self,
name: &str,
args: &[String],
user: Option<&User>,
) -> Option<bool> {
match name {
// isPremium() - check if user has premium subscription
"isPremium" => {
let username = user?.username.clone();
Some(self.premium_users.contains(&username))
}
// hasBetaAccess('feature') - check beta feature access
"hasBetaAccess" => {
let feature = args.get(0)?;
let user = user?;
// Admins always have beta access
if user.has_role("ADMIN") {
return Some(true);
}
// Check if feature is in beta and user has beta role
Some(
self.beta_features.contains(feature)
&& user.has_role("BETA_TESTER"),
)
}
// isOwner('resource_id') - check resource ownership
"isOwner" => {
let resource_id = args.get(0)?;
let user = user?;
// Your ownership logic
Some(self.check_ownership(&user.username, resource_id))
}
// Unknown function - return None to use default
_ => None,
}
}
}
impl CustomExpressionRoot {
fn check_ownership(&self, username: &str, resource_id: &str) -> bool {
// Your database lookup logic
true
}
}
Registering Custom Expressions
Register your custom ExpressionRoot with the security configuration:
use actix_security::http::security::expression::ExpressionEvaluator;
let custom_root = CustomExpressionRoot {
premium_users: vec!["vip_user".to_string()].into_iter().collect(),
beta_features: vec!["new_dashboard".to_string()].into_iter().collect(),
};
// Create evaluator with custom root
let evaluator = ExpressionEvaluator::with_root(Box::new(custom_root));
Using Custom Functions
Once registered, use your custom functions in expressions:
// Check premium status
#[pre_authorize("isPremium()")]
#[get("/premium/content")]
async fn premium_content(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Premium content")
}
// Check beta access
#[pre_authorize("hasBetaAccess('new_dashboard')")]
#[get("/beta/dashboard")]
async fn beta_dashboard(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Beta dashboard")
}
// Combine with built-in functions
#[pre_authorize("hasRole('USER') AND isPremium()")]
#[get("/premium/profile")]
async fn premium_profile(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Premium profile")
}
// Complex custom expression
#[pre_authorize("hasRole('ADMIN') OR (hasRole('USER') AND hasBetaAccess('feature'))")]
#[get("/feature")]
async fn feature(user: AuthenticatedUser) -> impl Responder {
HttpResponse::Ok().body("Feature")
}
Spring Security Comparison
Spring Security:
// Custom SecurityExpressionRoot
public class CustomSecurityExpressionRoot
extends SecurityExpressionRoot
implements MethodSecurityExpressionOperations {
public boolean isPremium() {
return premiumService.isPremium(getAuthentication().getName());
}
public boolean hasBetaAccess(String feature) {
return betaService.hasAccess(getAuthentication(), feature);
}
}
// Usage
@PreAuthorize("isPremium()")
public void premiumContent() {}
@PreAuthorize("hasBetaAccess('new_feature')")
public void betaFeature() {}
Actix Security:
// Custom ExpressionRoot
impl ExpressionRoot for CustomExpressionRoot {
fn evaluate_function(&self, name: &str, args: &[String], user: Option<&User>) -> Option<bool> {
match name {
"isPremium" => Some(self.premium_service.is_premium(user?)),
"hasBetaAccess" => Some(self.beta_service.has_access(user?, args.get(0)?)),
_ => None,
}
}
}
// Usage
#[pre_authorize("isPremium()")]
async fn premium_content() {}
#[pre_authorize("hasBetaAccess('new_feature')")]
async fn beta_feature() {}
Example: Organization-Based Access
#[derive(Clone)]
pub struct OrgExpressionRoot {
org_service: OrgService,
}
impl ExpressionRoot for OrgExpressionRoot {
fn evaluate_function(
&self,
name: &str,
args: &[String],
user: Option<&User>,
) -> Option<bool> {
match name {
// belongsToOrg('org_id') - user belongs to organization
"belongsToOrg" => {
let org_id = args.get(0)?;
let user = user?;
Some(self.org_service.user_belongs_to(&user.username, org_id))
}
// isOrgAdmin('org_id') - user is admin of organization
"isOrgAdmin" => {
let org_id = args.get(0)?;
let user = user?;
Some(self.org_service.is_org_admin(&user.username, org_id))
}
// hasOrgPermission('org_id', 'permission')
"hasOrgPermission" => {
let org_id = args.get(0)?;
let permission = args.get(1)?;
let user = user?;
Some(self.org_service.has_permission(&user.username, org_id, permission))
}
_ => None,
}
}
}
// Usage
#[pre_authorize("belongsToOrg('acme-corp')")]
async fn org_dashboard() {}
#[pre_authorize("isOrgAdmin('acme-corp') OR hasRole('SUPER_ADMIN')")]
async fn org_settings() {}
#[pre_authorize("hasOrgPermission('acme-corp', 'billing:manage')")]
async fn billing() {}
Best Practices
1. Return None for Unknown Functions
Allow fallback to default implementation:
fn evaluate_function(&self, name: &str, args: &[String], user: Option<&User>) -> Option<bool> {
match name {
"myFunction" => Some(/* ... */),
_ => None, // Important: let default handle unknown functions
}
}
2. Handle Missing User
Return false or None when user is required but missing:
"isPremium" => {
let user = user?; // Returns None if no user
Some(self.check_premium(&user.username))
}
3. Validate Arguments
Check for required arguments:
"hasFeature" => {
let feature = args.get(0)?; // Returns None if missing
Some(self.check_feature(feature))
}
4. Keep Functions Simple
Complex logic should live in services:
// Good
"isPremium" => Some(self.premium_service.is_premium(user?))
// Bad - too much logic in expression root
"isPremium" => {
let user = user?;
let subscription = db.query_subscription(&user.id)?;
Some(subscription.tier == "premium" && subscription.expires > now())
}
5. Document Your Functions
/// Custom expression functions for MyApp.
///
/// Available functions:
/// - `isPremium()` - Returns true if user has premium subscription
/// - `hasBetaAccess('feature')` - Returns true if user can access beta feature
/// - `isOrgMember('org_id')` - Returns true if user belongs to organization
impl ExpressionRoot for MyExpressionRoot { /* ... */ }
Security Headers
Protect your application with HTTP security headers middleware.
Overview
SecurityHeaders middleware adds important security headers to all responses:
- X-Content-Type-Options - Prevents MIME sniffing
- X-Frame-Options - Protects against clickjacking
- X-XSS-Protection - Legacy XSS protection
- Content-Security-Policy - Controls resource loading
- Strict-Transport-Security - Enforces HTTPS
- Referrer-Policy - Controls referrer information
- Permissions-Policy - Controls browser features
- Cache-Control - Controls caching behavior
Quick Start
use actix_security::http::security::SecurityHeaders;
App::new()
.wrap(SecurityHeaders::default())
.service(/* ... */)
Default Configuration
SecurityHeaders::default()
Adds these headers:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Referrer-Policy: strict-origin-when-cross-origin
Strict Configuration
SecurityHeaders::strict()
Maximum security with all headers:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Referrer-Policy: no-referrer
Permissions-Policy: geolocation=(), microphone=(), camera=()
Cache-Control: no-cache, no-store, must-revalidate
Custom Configuration
X-Frame-Options
Protect against clickjacking:
use actix_security::http::security::headers::FrameOptions;
// Block all framing (default)
SecurityHeaders::new().frame_options(FrameOptions::Deny)
// Allow same origin
SecurityHeaders::new().frame_options(FrameOptions::SameOrigin)
// Disable header
SecurityHeaders::new().frame_options(FrameOptions::Disabled)
Content-Security-Policy
Control resource loading:
// Basic policy
SecurityHeaders::new()
.content_security_policy("default-src 'self'")
// More permissive
SecurityHeaders::new()
.content_security_policy("default-src 'self'; img-src *; script-src 'self' cdn.example.com")
// Complex policy
SecurityHeaders::new()
.content_security_policy(
"default-src 'self'; \
script-src 'self' 'unsafe-inline' cdn.example.com; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: https:; \
font-src 'self' fonts.gstatic.com; \
connect-src 'self' api.example.com"
)
Strict-Transport-Security (HSTS)
Enforce HTTPS:
// Enable with 1 year max-age
SecurityHeaders::new().hsts(true, 31536000)
// With subdomains
SecurityHeaders::new()
.hsts(true, 31536000)
.hsts_include_subdomains(true)
// With preload (for HSTS preload list)
SecurityHeaders::new()
.hsts(true, 31536000)
.hsts_include_subdomains(true)
.hsts_preload(true)
Warning: Only enable HSTS preload if you're committed to HTTPS forever. It's difficult to reverse.
Referrer-Policy
Control referrer information:
use actix_security::http::security::headers::ReferrerPolicy;
// No referrer (maximum privacy)
SecurityHeaders::new().referrer_policy(ReferrerPolicy::NoReferrer)
// Same origin only
SecurityHeaders::new().referrer_policy(ReferrerPolicy::SameOrigin)
// Strict origin when cross-origin (default)
SecurityHeaders::new().referrer_policy(ReferrerPolicy::StrictOriginWhenCrossOrigin)
// No referrer when downgrade
SecurityHeaders::new().referrer_policy(ReferrerPolicy::NoReferrerWhenDowngrade)
Permissions-Policy
Control browser features:
// Disable geolocation, microphone, camera
SecurityHeaders::new()
.permissions_policy("geolocation=(), microphone=(), camera=()")
// Allow geolocation for self only
SecurityHeaders::new()
.permissions_policy("geolocation=(self), microphone=(), camera=()")
Cache-Control
Control caching:
// No caching (for sensitive data)
SecurityHeaders::new()
.cache_control("no-cache, no-store, must-revalidate")
// Private caching
SecurityHeaders::new()
.cache_control("private, max-age=3600")
Complete Example
use actix_web::{get, App, HttpServer, HttpResponse, Responder};
use actix_security::http::security::SecurityHeaders;
use actix_security::http::security::headers::{FrameOptions, ReferrerPolicy};
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Hello!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.wrap(
SecurityHeaders::new()
.frame_options(FrameOptions::SameOrigin)
.content_security_policy("default-src 'self'; img-src *")
.hsts(true, 31536000)
.hsts_include_subdomains(true)
.referrer_policy(ReferrerPolicy::StrictOriginWhenCrossOrigin)
.permissions_policy("geolocation=(), microphone=(), camera=()")
.cache_control("no-cache")
)
.service(index)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Middleware Order
Place SecurityHeaders after authentication middleware but before your routes:
App::new()
.wrap(SecurityHeaders::default()) // Adds security headers
.wrap(SecurityTransform::new() // Handles auth
.config_authenticator(/* ... */)
.config_authorizer(/* ... */))
.service(/* ... */)
Testing
use actix_web::{test, App, http::StatusCode};
#[actix_web::test]
async fn test_security_headers() {
let app = test::init_service(
App::new()
.wrap(SecurityHeaders::default())
.service(test_endpoint)
).await;
let req = test::TestRequest::get().uri("/test").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let headers = resp.headers();
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
}
Spring Security Comparison
Spring Security:
http.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true))
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
);
Actix Security:
SecurityHeaders::new()
.frame_options(FrameOptions::Deny)
.content_security_policy("default-src 'self'")
.hsts(true, 31536000)
.hsts_include_subdomains(true)
.referrer_policy(ReferrerPolicy::StrictOriginWhenCrossOrigin)
Security Recommendations
| Header | Recommended Value | Notes |
|---|---|---|
| X-Content-Type-Options | nosniff | Always enable |
| X-Frame-Options | DENY or SAMEORIGIN | Prevent clickjacking |
| Content-Security-Policy | App-specific | Start strict, relax as needed |
| Strict-Transport-Security | max-age=31536000 | Only for HTTPS sites |
| Referrer-Policy | strict-origin-when-cross-origin | Balance privacy/functionality |
| Permissions-Policy | Disable unused features | Camera, mic, geolocation |
Security Context
Access the current authenticated user from anywhere in your application.
Overview
SecurityContext provides thread-safe access to the current user using Tokio's task-local storage. This is useful when you need to access user information outside of handlers.
Basic Usage
use actix_security::http::security::SecurityContext;
// Get the current user
if let Some(user) = SecurityContext::get_user() {
println!("Current user: {}", user.username);
}
// Check role
if SecurityContext::has_role("ADMIN") {
// Admin-specific logic
}
// Check authority
if SecurityContext::has_authority("posts:write") {
// Permission-specific logic
}
API Reference
get_user
Returns the current authenticated user, if any.
pub fn get_user() -> Option<User>
Example:
match SecurityContext::get_user() {
Some(user) => println!("Logged in as: {}", user.username),
None => println!("Not authenticated"),
}
has_role
Checks if the current user has a specific role.
pub fn has_role(role: &str) -> bool
Example:
if SecurityContext::has_role("ADMIN") {
// Show admin controls
}
has_authority
Checks if the current user has a specific authority.
pub fn has_authority(authority: &str) -> bool
Example:
if SecurityContext::has_authority("posts:delete") {
// Show delete button
}
is_authenticated
Checks if there is an authenticated user.
pub fn is_authenticated() -> bool
Example:
if SecurityContext::is_authenticated() {
// User is logged in
}
run_with
Executes code with a specific user context.
pub async fn run_with<F, R>(user: Option<User>, f: F) -> R
where
F: Future<Output = R>,
Example:
let user = User::new("test".to_string(), "".to_string())
.roles(&["USER".into()]);
let result = SecurityContext::run_with(Some(user), async {
// Code here has access to the user via SecurityContext
SecurityContext::get_user()
}).await;
Use Cases
Service Layer Authorization
pub struct PostService;
impl PostService {
pub async fn delete_post(&self, post_id: i64) -> Result<(), ServiceError> {
// Check authorization in service layer
let user = SecurityContext::get_user()
.ok_or(ServiceError::Unauthorized)?;
if !user.has_role("ADMIN") && !user.has_authority("posts:delete") {
return Err(ServiceError::Forbidden);
}
// Proceed with deletion
self.repository.delete(post_id).await
}
}
Audit Logging
pub fn log_action(action: &str, resource: &str) {
let username = SecurityContext::get_user()
.map(|u| u.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
log::info!("AUDIT: {} performed {} on {}", username, action, resource);
}
// In handler
#[post("/posts")]
async fn create_post() -> impl Responder {
log_action("CREATE", "post");
// ...
}
Dynamic Query Filtering
pub async fn get_visible_posts(&self) -> Vec<Post> {
let user = SecurityContext::get_user();
match user {
Some(u) if u.has_role("ADMIN") => {
// Admins see all posts
self.repository.find_all().await
}
Some(u) => {
// Users see their own posts + published posts
self.repository.find_visible_for(&u.username).await
}
None => {
// Anonymous users see only published posts
self.repository.find_published().await
}
}
}
Conditional UI Elements (in templates)
pub struct TemplateContext {
pub can_edit: bool,
pub can_delete: bool,
pub is_admin: bool,
}
impl TemplateContext {
pub fn from_security_context() -> Self {
Self {
can_edit: SecurityContext::has_authority("posts:write"),
can_delete: SecurityContext::has_authority("posts:delete"),
is_admin: SecurityContext::has_role("ADMIN"),
}
}
}
How It Works
The security middleware sets up the context before handling each request:
// Simplified middleware flow
async fn call(&self, req: ServiceRequest) -> Result<ServiceResponse, Error> {
// 1. Authenticate user
let user = self.authenticator.authenticate(&req);
// 2. Run handler with security context
SecurityContext::run_with(user, async {
// 3. Your handler runs here with access to SecurityContext
self.service.call(req).await
}).await
}
Thread Safety
SecurityContext uses Tokio's task_local! macro, which provides:
- Task isolation - Each async task has its own context
- Thread safety - Safe to use across
.awaitpoints - No data races - Proper synchronization
// Safe to use across await points
async fn my_handler() {
let user = SecurityContext::get_user(); // Before await
some_async_operation().await;
let same_user = SecurityContext::get_user(); // After await
// Both return the same user
}
Testing with Security Context
#[tokio::test]
async fn test_with_security_context() {
let admin = User::new("admin".to_string(), "".to_string())
.roles(&["ADMIN".into()]);
SecurityContext::run_with(Some(admin), async {
assert!(SecurityContext::is_authenticated());
assert!(SecurityContext::has_role("ADMIN"));
assert_eq!(SecurityContext::get_user().unwrap().username, "admin");
}).await;
}
#[tokio::test]
async fn test_without_user() {
SecurityContext::run_with(None, async {
assert!(!SecurityContext::is_authenticated());
assert!(!SecurityContext::has_role("ADMIN"));
assert!(SecurityContext::get_user().is_none());
}).await;
}
Spring Security Comparison
Spring Security:
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
// Check role
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
// Admin logic
}
// Run with different context
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(newAuth);
SecurityContextHolder.setContext(context);
try {
// Code runs with new context
} finally {
SecurityContextHolder.clearContext();
}
Actix Security:
// Get current user
let user = SecurityContext::get_user();
let username = user.map(|u| u.username.clone());
// Check role
if SecurityContext::has_role("ADMIN") {
// Admin logic
}
// Run with different context
SecurityContext::run_with(Some(new_user), async {
// Code runs with new context
}).await;
Limitations
- Request scope only - Context is only available during request handling
- No cross-task sharing - Each spawned task needs its own context
- Async only - Uses Tokio's task-local storage
For spawned tasks, pass the user explicitly:
#[post("/process")]
async fn process(user: AuthenticatedUser) -> impl Responder {
let user_clone = user.clone();
tokio::spawn(async move {
// SecurityContext not available here
// Use user_clone directly
process_in_background(user_clone).await;
});
HttpResponse::Accepted().body("Processing")
}
CSRF Protection
Cross-Site Request Forgery (CSRF) protection prevents attackers from tricking users into performing unwanted actions.
Enabling CSRF Protection
Add the csrf feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["csrf"] }
Basic Usage
use actix_security::http::security::{CsrfProtection, CsrfConfig};
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::{App, HttpServer, cookie::Key};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let secret_key = Key::generate();
let csrf = CsrfProtection::new(CsrfConfig::default());
HttpServer::new(move || {
App::new()
// Session middleware is required for CSRF
.wrap(SessionMiddleware::new(
CookieSessionStore::default(),
secret_key.clone(),
))
.wrap(csrf.clone())
// ... routes
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Configuration Options
CsrfConfig::new()
// Name of the form field containing the token
.token_parameter("_csrf")
// Name of the HTTP header containing the token
.header_name("X-CSRF-TOKEN")
// Session key for storing the token
.session_key("csrf_token")
// Paths to exclude from CSRF checks
.ignore_path("/api/webhook")
.ignore_paths(vec!["/api/public/*"])
// Methods that don't require CSRF (safe methods)
// Default: GET, HEAD, OPTIONS, TRACE
.ignore_method(Method::GET)
Token in Forms
Include the CSRF token in your HTML forms:
<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<!-- other form fields -->
<button type="submit">Submit</button>
</form>
To get the CSRF token in your handler:
use actix_security::http::security::CsrfToken;
use actix_session::Session;
async fn show_form(session: Session) -> impl Responder {
// Generate or retrieve CSRF token
let token = CsrfToken::generate();
session.insert("csrf_token", &token.token).ok();
let html = format!(r#"
<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value="{}">
<button type="submit">Submit</button>
</form>
"#, token.token);
HttpResponse::Ok().content_type("text/html").body(html)
}
Token in AJAX Requests
For JavaScript/AJAX requests, include the token in a header:
// Get token from meta tag or cookie
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});
Token Repository
By default, tokens are stored in the session. You can customize this:
use actix_security::http::security::{CsrfTokenRepository, SessionCsrfTokenRepository};
// Session-based (default)
CsrfConfig::new()
.token_repository(SessionCsrfTokenRepository::new())
Error Handling
When CSRF validation fails, a 403 Forbidden response is returned by default.
use actix_security::http::security::CsrfError;
// CsrfError variants:
CsrfError::MissingToken // No token in request
CsrfError::InvalidToken // Token doesn't match
CsrfError::SessionError // Session storage issue
When to Use CSRF Protection
- Enable for: Form submissions, state-changing operations
- Disable for: APIs using token-based auth (JWT), webhooks, public endpoints
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
CsrfFilter | CsrfProtection middleware |
CsrfToken | CsrfToken |
CsrfTokenRepository | CsrfTokenRepository trait |
HttpSessionCsrfTokenRepository | SessionCsrfTokenRepository |
.csrf().disable() | .ignore_path() |
Rate Limiting
Rate limiting protects your application from brute-force attacks, denial-of-service (DoS) attempts, and excessive API usage.
Enabling Rate Limiting
Add the rate-limit feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["rate-limit"] }
Basic Usage
use actix_security::http::security::{RateLimiter, RateLimitConfig};
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let rate_limiter = RateLimiter::new(
RateLimitConfig::new()
.requests_per_minute(60)
);
HttpServer::new(move || {
App::new()
.wrap(rate_limiter.clone())
// ... routes
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Configuration Options
RateLimitConfig::new()
// Maximum requests per window
.max_requests(100)
// Time window
.window(Duration::from_secs(60))
// Shorthand for requests_per_minute
.requests_per_minute(60)
// Burst capacity (for token bucket)
.burst_size(10)
// Algorithm selection
.algorithm(RateLimitAlgorithm::SlidingWindow)
// Add rate limit headers to response
.add_headers(true)
// Exclude certain paths
.exclude_paths(vec!["/health", "/metrics"])
Algorithms
Fixed Window
Counts requests in fixed time intervals. Simple but can allow bursts at window boundaries.
.algorithm(RateLimitAlgorithm::FixedWindow)
Sliding Window
Smooths out the window boundary issue by considering a weighted average.
.algorithm(RateLimitAlgorithm::SlidingWindow)
Token Bucket
Allows controlled bursting while maintaining overall rate limits.
.algorithm(RateLimitAlgorithm::TokenBucket)
.burst_size(10) // Allow burst of 10 requests
Key Extraction
Rate limiting can be applied per IP address, per user, or with custom logic:
use actix_security::http::security::KeyExtractor;
RateLimitConfig::new()
.key_extractor(KeyExtractor::IpAddress) // Per IP (default)
.key_extractor(KeyExtractor::User) // Per authenticated user
.key_extractor(KeyExtractor::IpAndEndpoint) // Per IP + endpoint
.key_extractor(KeyExtractor::Header("X-API-Key".to_string())) // Per API key
Response Headers
When add_headers is enabled, the following headers are added:
X-RateLimit-Limit: Maximum requests allowedX-RateLimit-Remaining: Requests remaining in current windowX-RateLimit-Reset: Unix timestamp when the window resets
Preset Configurations
// Strict for login endpoints (5/minute)
RateLimitConfig::strict_login()
// Lenient for API (1000/minute)
RateLimitConfig::lenient_api()
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
| Custom filter or Bucket4j | RateLimiter middleware |
@RateLimiter annotation | Configuration-based |
Account Locking
Account locking automatically locks user accounts after multiple failed login attempts, protecting against brute-force attacks.
Enabling Account Locking
Add the account-lock feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["account-lock"] }
Basic Usage
use actix_security::http::security::{
AccountLockManager, LockConfig, check_login
};
use std::time::Duration;
// Create lock manager
let lock_manager = AccountLockManager::new(
LockConfig::new()
.max_attempts(5)
.lockout_duration(Duration::from_secs(15 * 60))
);
// In your login handler:
async fn login(
form: web::Form<LoginForm>,
lock_manager: web::Data<AccountLockManager>,
) -> impl Responder {
let username = &form.username;
// Check if account is locked
let result = check_login(&lock_manager, username).await;
if !result.is_allowed() {
return HttpResponse::Forbidden().body("Account locked");
}
// Attempt authentication
if authenticate(username, &form.password) {
lock_manager.record_success(username).await;
HttpResponse::Ok().body("Logged in")
} else {
lock_manager.record_failure(username).await;
let remaining = lock_manager.get_remaining_attempts(username).await;
HttpResponse::Unauthorized()
.body(format!("{} attempts remaining", remaining))
}
}
Configuration Options
LockConfig::new()
// Maximum failed attempts before lock
.max_attempts(5)
// How long to lock the account
.lockout_duration(Duration::from_secs(15 * 60))
// Reset counter on successful login
.reset_on_success(true)
// Progressive lockout (doubles duration each time)
.progressive_lockout(true)
Preset Configurations
// Strict: 3 attempts, 30 minute lockout
LockConfig::strict()
// Lenient: 10 attempts, 5 minute lockout
LockConfig::lenient()
Lock Status
use actix_security::http::security::LockStatus;
let status = lock_manager.get_lock_status(&username).await;
match status {
LockStatus::Unlocked => { /* Account is accessible */ }
LockStatus::TemporarilyLocked { until, reason } => {
// Locked until specified time
}
LockStatus::PermanentlyLocked { reason } => {
// Requires admin intervention
}
}
IP Address Tracking
Track which IP addresses have attempted to access an account:
// Record failure with IP
lock_manager
.record_failure_with_ip(&username, Some(&ip_address))
.await;
// Get account statistics
let stats = lock_manager.get_account_stats(&username).await;
println!("Failed attempts: {}", stats.failed_attempts);
println!("Associated IPs: {:?}", stats.associated_ips);
Manual Lock/Unlock
// Manually unlock an account
lock_manager.unlock(&username).await;
// Permanently lock an account
lock_manager
.lock_permanently(&username, "Suspicious activity detected")
.await;
Check Result
The check_login function returns detailed information:
let result = check_login(&lock_manager, &username).await;
match result {
LoginCheckResult::Allowed { remaining_attempts } => {
println!("{} attempts remaining", remaining_attempts);
}
LoginCheckResult::Blocked { message, unlock_time } => {
println!("Blocked: {}", message);
if let Some(time) = unlock_time {
println!("Unlocks at: {:?}", time);
}
}
}
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
LockedException | LockStatus::TemporarilyLocked |
AccountStatusUserDetailsChecker | check_login() |
JdbcUserDetailsManager.lockUser() | lock_permanently() |
Audit Logging
Audit logging captures security-relevant events for compliance, debugging, and threat detection.
Enabling Audit Logging
Add the audit feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["audit"] }
Basic Usage
use actix_security::http::security::{
AuditLogger, SecurityEvent, SecurityEventType, StdoutHandler
};
// Create audit logger with stdout handler
let logger = AuditLogger::new()
.add_handler(StdoutHandler::new());
// Log events
logger.log_login_success("admin", "192.168.1.1");
logger.log_login_failure("admin", "192.168.1.1", "Invalid password");
Event Types
use actix_security::http::security::SecurityEventType;
// Authentication events
SecurityEventType::AuthenticationSuccess
SecurityEventType::AuthenticationFailure
SecurityEventType::Logout
// Session events
SecurityEventType::SessionCreated
// Authorization events
SecurityEventType::AccessGranted
SecurityEventType::AccessDenied
// Security events
SecurityEventType::AccountLocked
SecurityEventType::RateLimitExceeded
SecurityEventType::BruteForceDetected
// Custom events
SecurityEventType::Custom("password_changed".to_string())
Event Severity
use actix_security::http::security::SecurityEventSeverity;
SecurityEventSeverity::Info // Normal operations
SecurityEventSeverity::Warning // Potential issues
SecurityEventSeverity::Error // Failed operations
SecurityEventSeverity::Critical // Security incidents
Creating Events
use actix_security::http::security::SecurityEvent;
let event = SecurityEvent::new(SecurityEventType::AuthenticationFailure)
.username("admin")
.ip_address("192.168.1.1")
.resource("/admin/dashboard")
.error("Invalid credentials")
.severity(SecurityEventSeverity::Warning)
.add_detail("attempt_number", "3");
logger.log(event);
Convenience Methods
// Login success
logger.log_login_success(&username, &ip);
// Login failure
logger.log_login_failure(&username, &ip, "Invalid password");
// Access denied
logger.log(
SecurityEvent::new(SecurityEventType::AccessDenied)
.username(&username)
.resource("/admin")
.ip_address(&ip)
);
Event Handlers
StdoutHandler
Prints events to standard output:
let handler = StdoutHandler::new();
InMemoryEventStore
Stores events in memory (useful for testing):
use actix_security::http::security::InMemoryEventStore;
let store = InMemoryEventStore::new();
let logger = AuditLogger::new().add_handler(store.clone());
// Later, retrieve events
let events = store.get_events();
Custom Handler
Implement the SecurityEventHandler trait:
use actix_security::http::security::{SecurityEventHandler, SecurityEvent};
struct DatabaseHandler {
// database connection
}
impl SecurityEventHandler for DatabaseHandler {
fn handle(&self, event: &SecurityEvent) {
// Store in database
}
}
let logger = AuditLogger::new()
.add_handler(DatabaseHandler::new());
Global Logger
Set up a global logger accessible from anywhere:
use actix_security::http::security::{init_global_logger, audit_log, global_logger};
// Initialize once at startup
init_global_logger(AuditLogger::new().add_handler(StdoutHandler::new()));
// Use anywhere
audit_log(SecurityEvent::new(SecurityEventType::AccessGranted)
.username("admin")
.resource("/api/users"));
// Or get the logger instance
if let Some(logger) = global_logger() {
logger.log_login_success("admin", "127.0.0.1");
}
JSON Output
Events can be serialized to JSON (enabled by default with the audit feature):
{
"event_type": "AuthenticationFailure",
"severity": "Warning",
"timestamp": "2024-01-15T10:30:00Z",
"username": "admin",
"ip_address": "192.168.1.1",
"error": "Invalid credentials",
"details": {
"attempt_number": "3"
}
}
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
AuthenticationEventPublisher | AuditLogger |
AbstractAuthenticationEvent | SecurityEvent |
@EventListener | SecurityEventHandler trait |
Remember-Me Authentication
Remember-me authentication allows users to stay logged in across browser sessions using a persistent token stored in a cookie.
Enabling Remember-Me
Add the remember-me feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["remember-me"] }
Note: The remember-me feature automatically enables the session feature.
Basic Usage
use actix_security::http::security::{RememberMeServices, RememberMeConfig};
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::{App, HttpServer, cookie::Key};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let secret_key = Key::generate();
let remember_me = RememberMeServices::new(
RememberMeConfig::new()
.key("my-secret-key")
.validity_seconds(60 * 60 * 24 * 14) // 14 days
.cookie_name("remember-me")
);
HttpServer::new(move || {
App::new()
.wrap(SessionMiddleware::new(
CookieSessionStore::default(),
secret_key.clone(),
))
// Configure with SecurityTransform
// ...
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Configuration Options
RememberMeConfig::new()
// Secret key for token generation
.key("your-secret-key-here")
// Token validity period (default: 14 days)
.validity_seconds(60 * 60 * 24 * 14)
// Cookie name (default: "remember-me")
.cookie_name("remember-me")
// Cookie path (default: "/")
.cookie_path("/")
// Secure cookie (HTTPS only)
.secure(true)
// HttpOnly cookie (not accessible via JavaScript)
.http_only(true)
// SameSite policy
.same_site(SameSite::Lax)
Login with Remember-Me
When processing login, check if the user wants to be remembered:
use actix_security::http::security::RememberMeServices;
use actix_web::{web, HttpResponse, HttpRequest};
async fn login(
form: web::Form<LoginForm>,
remember_me: web::Data<RememberMeServices>,
req: HttpRequest,
) -> HttpResponse {
// Authenticate user
if let Some(user) = authenticate(&form.username, &form.password) {
// Create session
create_session(&user);
// If remember-me checkbox was checked
if form.remember_me {
// Generate and set remember-me cookie
let cookie = remember_me.create_token(&user);
HttpResponse::Found()
.cookie(cookie)
.insert_header(("Location", "/dashboard"))
.finish()
} else {
HttpResponse::Found()
.insert_header(("Location", "/dashboard"))
.finish()
}
} else {
HttpResponse::Unauthorized().body("Invalid credentials")
}
}
#[derive(serde::Deserialize)]
struct LoginForm {
username: String,
password: String,
#[serde(default)]
remember_me: bool,
}
Auto-Login from Remember-Me Token
Check for remember-me token on requests:
use actix_security::http::security::RememberMeServices;
async fn check_remember_me(
remember_me: web::Data<RememberMeServices>,
session: Session,
req: HttpRequest,
) -> Option<User> {
// Check if already logged in
if let Some(user) = session.get::<User>("user").ok().flatten() {
return Some(user);
}
// Try to authenticate from remember-me cookie
if let Some(user) = remember_me.auto_login(&req) {
// Create new session
session.insert("user", &user).ok();
return Some(user);
}
None
}
Logout with Remember-Me
Clear the remember-me cookie on logout:
async fn logout(
remember_me: web::Data<RememberMeServices>,
session: Session,
) -> HttpResponse {
// Clear session
session.purge();
// Clear remember-me cookie
let removal_cookie = remember_me.logout_cookie();
HttpResponse::Found()
.cookie(removal_cookie)
.insert_header(("Location", "/login"))
.finish()
}
Token Format
The remember-me token contains:
base64(username:expiration:signature)
Where:
username: The user's identifierexpiration: Unix timestamp when token expiressignature: HMAC-SHA256 of username + expiration + secret key
HTML Form Example
<form method="POST" action="/login">
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<label>
<input type="checkbox" name="remember_me" value="true">
Remember me for 14 days
</label>
<button type="submit">Login</button>
</form>
Token Repository
For persistent token storage (more secure):
use actix_security::http::security::{
PersistentRememberMeServices,
PersistentTokenRepository,
};
// Implement your own token repository
struct DatabaseTokenRepository { /* ... */ }
impl PersistentTokenRepository for DatabaseTokenRepository {
fn create_token(&self, username: &str) -> RememberMeToken;
fn get_token(&self, series: &str) -> Option<RememberMeToken>;
fn update_token(&self, series: &str, token_value: &str, last_used: DateTime);
fn remove_user_tokens(&self, username: &str);
}
let remember_me = PersistentRememberMeServices::new(
DatabaseTokenRepository::new(db_pool),
"secret-key",
);
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
RememberMeServices | RememberMeServices |
RememberMeConfigurer | RememberMeConfig |
TokenBasedRememberMeServices | RememberMeServices (default) |
PersistentTokenBasedRememberMeServices | PersistentRememberMeServices |
PersistentTokenRepository | PersistentTokenRepository trait |
.rememberMe().key() | .key() |
.tokenValiditySeconds() | .validity_seconds() |
Security Considerations
- Strong Secret Key: Use a cryptographically random key, at least 32 bytes
- Secure Cookies: Enable
secure(true)in production (HTTPS only) - HttpOnly: Always use
http_only(true)to prevent XSS attacks - Token Rotation: Consider using persistent tokens with rotation for better security
- Limited Scope: Remember-me should not grant access to sensitive operations
- Session Binding: Optionally bind remember-me tokens to additional factors (IP, user-agent)
Best Practices
- Don't use remember-me for sensitive operations (payments, password changes)
- Provide users a way to invalidate all remember-me tokens (logout everywhere)
- Monitor for suspicious remember-me usage patterns
- Consider shorter validity periods for higher security requirements
Channel Security
Channel security ensures that certain URLs are only accessible over secure (HTTPS) connections, automatically redirecting HTTP requests to HTTPS when needed.
Basic Usage
Channel security is always available (no feature flag needed):
use actix_security::http::security::{ChannelSecurity, ChannelSecurityConfig};
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let channel_security = ChannelSecurity::new(
ChannelSecurityConfig::new()
.require_secure("/login")
.require_secure("/admin/**")
.require_secure("/api/payments/**")
);
HttpServer::new(move || {
App::new()
.wrap(channel_security.clone())
// ... routes
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Configuration Options
ChannelSecurityConfig::new()
// Require HTTPS for specific paths (supports ant-style patterns)
.require_secure("/login")
.require_secure("/admin/**")
.require_secure("/api/*/sensitive")
// Require HTTP for specific paths (optional, for debugging)
.require_insecure("/health")
.require_insecure("/metrics")
// Allow both HTTP and HTTPS
.any_channel("/public/**")
// Configure port mapping for redirects
.port_mapper(PortMapper::new()
.map(80, 443)
.map(8080, 8443))
// Redirect status code (default: 302)
.redirect_status(StatusCode::MOVED_PERMANENTLY) // 301
Channel Requirements
Three types of channel requirements:
use actix_security::http::security::ChannelRequirement;
// Require HTTPS
ChannelRequirement::Secure
// Require HTTP (rarely used)
ChannelRequirement::Insecure
// Allow both
ChannelRequirement::Any
HTTPS for All Routes
To require HTTPS for the entire application:
let channel_security = ChannelSecurity::new(
ChannelSecurityConfig::new()
.require_secure("/**")
.except("/health") // Except health checks
);
Port Mapping
Configure port mapping for proper redirects:
use actix_security::http::security::PortMapper;
// Standard ports
let mapper = PortMapper::default(); // 80 -> 443
// Custom ports (e.g., development)
let mapper = PortMapper::new()
.map(8080, 8443)
.map(3000, 3443);
ChannelSecurityConfig::new()
.port_mapper(mapper)
Behind a Reverse Proxy
When behind a reverse proxy (nginx, load balancer), use the X-Forwarded-Proto header:
ChannelSecurityConfig::new()
.trust_proxy_headers(true) // Trust X-Forwarded-Proto
.require_secure("/api/**")
This checks the X-Forwarded-Proto header instead of the actual connection protocol.
Redirect Behavior
When an HTTP request is made to a secure-only path:
- The middleware intercepts the request
- Constructs an HTTPS URL with the same path and query string
- Returns a redirect response (302 by default)
Example:
- Request:
http://example.com/login?next=/dashboard - Redirect:
https://example.com/login?next=/dashboard
Conditional Channel Security
Apply channel security based on environment:
let config = if std::env::var("PRODUCTION").is_ok() {
ChannelSecurityConfig::new()
.require_secure("/**")
} else {
// Development: no HTTPS requirement
ChannelSecurityConfig::new()
.any_channel("/**")
};
Combining with Security Headers
Often used together with HSTS:
use actix_security::http::security::{ChannelSecurity, SecurityHeaders};
App::new()
.wrap(channel_security)
.wrap(SecurityHeaders::default()) // Includes HSTS
Error Responses
When redirect is not possible (e.g., POST request):
ChannelSecurityConfig::new()
.require_secure("/api/**")
.on_insecure_request(|req| {
// Return 403 instead of redirect for API calls
HttpResponse::Forbidden()
.body("HTTPS required for API access")
})
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
requiresChannel() | ChannelSecurityConfig |
.requiresSecure() | .require_secure() |
.requiresInsecure() | .require_insecure() |
.anyRequest().requiresSecure() | .require_secure("/**") |
PortMapper | PortMapper |
ChannelDecisionManager | ChannelSecurity middleware |
Example: E-Commerce Security
ChannelSecurityConfig::new()
// All authentication must be secure
.require_secure("/login")
.require_secure("/register")
.require_secure("/forgot-password")
// All account pages must be secure
.require_secure("/account/**")
// All payment processing must be secure
.require_secure("/checkout/**")
.require_secure("/api/payments/**")
// Admin area must be secure
.require_secure("/admin/**")
// Public pages can use either
.any_channel("/products/**")
.any_channel("/search")
Best Practices
- Production: Require HTTPS for all routes (
/**) - Sensitive Data: Always require HTTPS for login, payments, personal data
- HSTS: Combine with HSTS header for additional security
- Monitoring: Exclude health check endpoints from HTTPS requirement
- API: Return 403 instead of redirect for API endpoints
- Development: Use self-signed certificates or disable in development only
LDAP Authentication
LDAP authentication allows you to authenticate users against an LDAP or Active Directory server.
Enabling LDAP Authentication
Add the ldap feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["ldap"] }
Basic Usage
use actix_security::http::security::{LdapAuthenticator, LdapConfig};
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let ldap_config = LdapConfig::new()
.url("ldap://ldap.example.com:389")
.base_dn("dc=example,dc=com")
.user_search_filter("(uid={0})")
.bind_dn("cn=admin,dc=example,dc=com")
.bind_password("admin_password");
let authenticator = LdapAuthenticator::new(ldap_config);
HttpServer::new(move || {
App::new()
// Configure with SecurityTransform
// ...
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Configuration Options
LdapConfig::new()
// LDAP server URL
.url("ldap://ldap.example.com:389")
// Base DN for user searches
.base_dn("dc=example,dc=com")
// Filter to find users ({0} is replaced with username)
.user_search_filter("(uid={0})")
// Service account for binding
.bind_dn("cn=admin,dc=example,dc=com")
.bind_password("admin_password")
// Group configuration
.group_search_base("ou=groups,dc=example,dc=com")
.group_search_filter("(member={0})")
// Map LDAP groups to roles
.group_role_mapping("cn=admins,ou=groups,dc=example,dc=com", "ADMIN")
.group_role_mapping("cn=users,ou=groups,dc=example,dc=com", "USER")
Active Directory Configuration
For Active Directory, use the preset configuration:
let config = LdapConfig::active_directory(
"ldap://ad.company.com:389",
"dc=company,dc=com"
)
.bind_dn("cn=service,cn=users,dc=company,dc=com")
.bind_password("service_password");
This configures:
- User search filter:
(sAMAccountName={0}) - Group membership attribute:
memberOf
User Attributes Mapping
Configure which LDAP attributes map to user properties:
LdapConfig::new()
.username_attribute("uid") // Default: "uid"
.display_name_attribute("cn") // Display name
.email_attribute("mail") // Email address
.group_attribute("memberOf") // Group membership
Group to Role Mapping
Map LDAP groups to application roles:
let config = LdapConfig::new()
// ... base configuration
.group_role_mapping("cn=admins,ou=groups,dc=example,dc=com", "ADMIN")
.group_role_mapping("cn=users,ou=groups,dc=example,dc=com", "USER")
.group_role_mapping("cn=managers,ou=groups,dc=example,dc=com", "MANAGER");
Testing with MockLdapClient
For testing without a real LDAP server:
use actix_security::http::security::MockLdapClient;
let mut mock = MockLdapClient::new();
// Add test users
mock.add_user(
"john",
"password123",
vec![
("cn".into(), vec!["John Doe".into()]),
("mail".into(), vec!["john@example.com".into()]),
],
vec!["cn=users,ou=groups,dc=example,dc=com".into()],
);
let authenticator = LdapAuthenticator::with_client(config, mock);
Error Handling
LDAP authentication can fail for various reasons:
use actix_security::http::security::LdapError;
match result {
Err(LdapError::ConnectionFailed) => // LDAP server unreachable
Err(LdapError::BindFailed) => // Invalid service account credentials
Err(LdapError::UserNotFound) => // User not in directory
Err(LdapError::AuthenticationFailed) => // Invalid password
Err(LdapError::SearchFailed) => // LDAP search error
}
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
LdapAuthenticationProvider | LdapAuthenticator |
LdapContextSource | LdapConfig |
LdapUserSearch | .user_search_filter() |
DefaultLdapAuthoritiesPopulator | .group_role_mapping() |
ActiveDirectoryLdapAuthenticationProvider | LdapConfig::active_directory() |
Best Practices
- Use TLS: Connect using
ldaps://or STARTTLS for production - Service Account: Use a dedicated service account for binding
- Principle of Least Privilege: Service account should only have read access
- Connection Pooling: Consider connection pooling for high-traffic applications
- Timeout Configuration: Set appropriate timeouts for LDAP operations
SAML 2.0 Authentication
SAML 2.0 (Security Assertion Markup Language) enables Single Sign-On (SSO) with enterprise identity providers like Okta, Azure AD, ADFS, and Google Workspace.
Enabling SAML Authentication
Add the saml feature to your Cargo.toml:
[dependencies]
actix-security = { version = "0.2", features = ["saml"] }
Basic Usage
use actix_security::http::security::{SamlAuthenticator, SamlConfig};
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let saml_config = SamlConfig::new()
.sp_entity_id("https://myapp.example.com")
.sp_acs_url("https://myapp.example.com/saml/acs")
.idp_entity_id("https://idp.example.com")
.idp_sso_url("https://idp.example.com/sso")
.idp_certificate(include_str!("../idp_cert.pem"));
let authenticator = SamlAuthenticator::new(saml_config);
HttpServer::new(move || {
App::new()
// Configure routes for SAML
// ...
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Provider Presets
Use preset configurations for common identity providers:
Okta
let config = SamlConfig::okta(
"dev-123456", // Okta org subdomain
"0oa1234567890abcdef", // App ID
"https://myapp.example.com", // SP Entity ID
"https://myapp.example.com/saml/acs", // ACS URL
)
.idp_certificate(include_str!("okta_cert.pem"));
Azure AD
let config = SamlConfig::azure_ad(
"tenant-id-here", // Azure tenant ID
"app-id-here", // Azure app ID
"https://myapp.example.com", // SP Entity ID
)
.idp_certificate(include_str!("azure_cert.pem"));
Google Workspace
let config = SamlConfig::google_workspace(
"https://myapp.example.com", // SP Entity ID
"https://myapp.example.com/saml/acs", // ACS URL
)
.idp_certificate(include_str!("google_cert.pem"));
ADFS
let config = SamlConfig::adfs(
"https://adfs.company.com", // ADFS server URL
"https://myapp.example.com", // SP Entity ID
"https://myapp.example.com/saml/acs", // ACS URL
)
.idp_certificate(include_str!("adfs_cert.pem"));
Configuration Options
SamlConfig::new()
// Service Provider (your application) settings
.sp_entity_id("https://myapp.example.com")
.sp_acs_url("https://myapp.example.com/saml/acs")
.sp_slo_url("https://myapp.example.com/saml/slo") // Single Logout
// Identity Provider settings
.idp_entity_id("https://idp.example.com")
.idp_sso_url("https://idp.example.com/sso")
.idp_slo_url("https://idp.example.com/slo")
.idp_certificate("-----BEGIN CERTIFICATE-----...")
// Request settings
.force_authn(false) // Force re-authentication
.allow_clock_skew(Duration::from_secs(300)) // 5 minutes tolerance
// Attribute mapping
.username_attribute("NameID") // Which attribute is username
.email_attribute("email")
.roles_attribute("groups")
SAML Routes
You need to set up routes to handle SAML flow:
use actix_security::http::security::{SamlAuthenticator, AuthnRequest};
use actix_web::{web, HttpResponse};
// Initiate SAML login
async fn saml_login(saml: web::Data<SamlAuthenticator>) -> HttpResponse {
let authn_request = saml.create_authn_request();
let redirect_url = saml.get_redirect_url(&authn_request);
HttpResponse::Found()
.insert_header(("Location", redirect_url))
.finish()
}
// Handle SAML response (Assertion Consumer Service)
async fn saml_acs(
saml: web::Data<SamlAuthenticator>,
form: web::Form<SamlResponseForm>,
) -> HttpResponse {
match saml.validate_response(&form.SAMLResponse) {
Ok(user) => {
// Store user in session
HttpResponse::Found()
.insert_header(("Location", "/"))
.finish()
}
Err(e) => HttpResponse::Unauthorized().body(format!("SAML error: {:?}", e))
}
}
// Configure routes
App::new()
.route("/saml/login", web::get().to(saml_login))
.route("/saml/acs", web::post().to(saml_acs))
SP Metadata Generation
Generate SAML metadata for your IdP:
let metadata = saml_config.generate_sp_metadata();
// Serve at /saml/metadata
async fn saml_metadata(saml: web::Data<SamlConfig>) -> HttpResponse {
let metadata = saml.generate_sp_metadata();
HttpResponse::Ok()
.content_type("application/xml")
.body(metadata)
}
Assertion Validation
The library validates SAML assertions for:
- Signature: Verifies IdP signature using configured certificate
- Audience: Ensures assertion is intended for your SP
- Timing: Checks NotBefore and NotOnOrAfter conditions
- Replay: Validates InResponseTo matches pending request
Error Handling
use actix_security::http::security::SamlError;
match result {
Err(SamlError::InvalidSignature) => // Signature verification failed
Err(SamlError::AudienceMismatch) => // Wrong SP entity ID
Err(SamlError::AssertionExpired) => // Assertion timing invalid
Err(SamlError::InvalidRequest) => // Malformed SAML request
Err(SamlError::IdpError(msg)) => // IdP returned an error
}
Group/Role Mapping
Map SAML groups to application roles:
let config = SamlConfig::new()
// ... base configuration
.roles_attribute("groups") // Attribute containing groups
.role_mapping("IdP-Admins", "ADMIN")
.role_mapping("IdP-Users", "USER");
Single Logout (SLO)
Support for SAML Single Logout:
// Initiate logout
async fn saml_logout(saml: web::Data<SamlAuthenticator>) -> HttpResponse {
let logout_request = saml.create_logout_request(&user);
let redirect_url = saml.get_slo_redirect_url(&logout_request);
// Clear local session
// ...
HttpResponse::Found()
.insert_header(("Location", redirect_url))
.finish()
}
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
Saml2LoginConfigurer | SamlConfig |
RelyingPartyRegistration | SamlConfig builder methods |
Saml2AuthenticationRequestResolver | create_authn_request() |
OpenSaml4AuthenticationProvider | SamlAuthenticator |
Saml2MetadataFilter | generate_sp_metadata() |
Security Considerations
- Use HTTPS: Always use HTTPS for ACS URL in production
- Validate Signatures: Never disable signature validation
- Certificate Management: Keep IdP certificates up to date
- Clock Synchronization: Ensure servers have synchronized time
- Secure Storage: Protect SP private keys if using signed requests
Architecture
Understanding Actix Security's internal architecture.
Overview
Actix Security follows a middleware-based architecture inspired by Spring Security's filter chain. The security flow is:
Request → SecurityTransform → SecurityService → Your Handler → Response
↓ ↓
Authenticator Authorizer
Core Components
SecurityTransform
The entry point for security. Implements Actix Web's Transform trait.
pub struct SecurityTransform<A, Z>
where
A: Authenticator,
Z: Authorizer,
{
authenticator_factory: Box<dyn Fn() -> A>,
authorizer_factory: Box<dyn Fn() -> Z>,
}
Responsibilities:
- Creates
SecurityServicefor each worker - Provides factory functions for authenticator and authorizer
SecurityService
Wraps your service and applies security checks.
pub struct SecurityService<S, A, Z>
where
A: Authenticator,
Z: Authorizer,
{
service: S,
authenticator: A,
authorizer: Z,
}
Responsibilities:
- Extracts user via authenticator
- Checks access via authorizer
- Sets up SecurityContext
- Calls your service if authorized
Authenticator Trait
Defines how to extract user identity from requests.
pub trait Authenticator: Clone + Send + Sync + 'static {
fn authenticate(&self, req: &ServiceRequest) -> Option<User>;
}
Implementations:
MemoryAuthenticator- In-memory user store
Authorizer Trait
Defines how to check access permissions.
pub trait Authorizer: Clone + Send + Sync + 'static {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult;
}
pub enum AuthorizationResult {
Granted,
Denied,
LoginRequired,
}
Implementations:
RequestMatcherAuthorizer- URL pattern-based authorization
Request Flow
1. Request arrives
↓
2. SecurityService.call() invoked
↓
3. Authenticator.authenticate()
├─ Success: User extracted
└─ Failure: user = None
↓
4. Authorizer.authorize(user, request)
├─ Granted: Continue
├─ Denied: 403 Forbidden
└─ LoginRequired: 401 or redirect
↓
5. SecurityContext.run_with(user, ...)
↓
6. Your handler executes
├─ Method security macros check
└─ Handler code runs
↓
7. Response returned
Module Structure
actix-security/ # Unified crate (recommended)
├── Cargo.toml # Re-exports core + codegen
└── src/lib.rs # Unified exports
core/ # actix-security-core
├── http/
│ ├── error.rs # AuthError type
│ └── security/
│ ├── mod.rs # Public exports
│ ├── config.rs # Traits (Authenticator, Authorizer)
│ ├── user.rs # User model
│ ├── extractor.rs # AuthenticatedUser extractor
│ ├── context.rs # SecurityContext
│ ├── middleware.rs# SecurityTransform, SecurityService
│ ├── authenticator/
│ │ └── memory.rs
│ ├── authorizer/
│ │ ├── access.rs
│ │ └── request_matcher.rs
│ ├── crypto/
│ │ ├── argon2.rs
│ │ ├── noop.rs
│ │ └── delegating.rs
│ ├── expression/
│ │ ├── ast.rs
│ │ ├── parser.rs
│ │ ├── evaluator.rs
│ │ └── root.rs
│ ├── headers.rs # SecurityHeaders middleware
│ └── manager.rs # Factory methods
codegen/ # actix-security-codegen
├── lib.rs # Macro exports
├── secured.rs # #[secured] macro
├── pre_authorize.rs # #[pre_authorize] macro
└── simple.rs # permit_all, deny_all, roles_allowed
Proc Macro Architecture
Compile-Time Flow
#[pre_authorize("hasRole('ADMIN')")]
↓
Parse expression (compile-time)
↓
Build AST
↓
Generate Rust code
↓
Inject into handler
Expression Compilation
// Input expression
"hasRole('ADMIN') OR hasAuthority('write')"
// Parsed AST
Binary {
op: Or,
left: Function("hasRole", ["ADMIN"]),
right: Function("hasAuthority", ["write"]),
}
// Generated Rust
if !(user.has_role("ADMIN") || user.has_authority("write")) {
return Err(AuthError::Forbidden);
}
Thread Safety
All security components are designed to be thread-safe:
// All traits require these bounds
pub trait Authenticator: Clone + Send + Sync + 'static { }
pub trait Authorizer: Clone + Send + Sync + 'static { }
SecurityContext uses Tokio's task-local storage for safe async access:
tokio::task_local! {
static SECURITY_CONTEXT: RefCell<Option<User>>;
}
Spring Security Comparison
| Spring Security | Actix Security |
|---|---|
SecurityFilterChain | SecurityTransform |
AuthenticationManager | Authenticator trait |
AuthorizationManager | Authorizer trait |
UserDetails | User |
Authentication | AuthenticatedUser |
SecurityContext | SecurityContext |
MethodSecurityExpressionRoot | ExpressionRoot trait |
Extensibility Points
- Custom Authenticator - Implement
Authenticatortrait - Custom Authorizer - Implement
Authorizertrait - Custom Password Encoder - Implement
PasswordEncodertrait - Custom Expressions - Implement
ExpressionRoottrait
Design Principles
- Compile-time safety - Catch errors at compile time
- Zero-cost abstractions - No runtime overhead for unused features
- Explicit over implicit - Clear, readable security configuration
- Familiar API - Similar to Spring Security for easy adoption
Extending the Framework
Actix Security is designed to be extensible. This guide covers the main extension points.
Extension Points Overview
| Extension Point | Purpose | Trait/Type |
|---|---|---|
| Authentication | Custom user extraction | Authenticator |
| Authorization | Custom access control | Authorizer |
| Password Encoding | Custom hashing | PasswordEncoder |
| Expressions | Custom functions | ExpressionRoot |
Custom Authenticator
Extract users from custom sources (database, JWT, OAuth, etc.).
Implement the Trait
use actix_security::http::security::config::Authenticator;
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
#[derive(Clone)]
pub struct DatabaseAuthenticator {
pool: PgPool,
encoder: Argon2PasswordEncoder,
}
impl Authenticator for DatabaseAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// 1. Extract credentials
let auth_header = req.headers().get("Authorization")?;
let (username, password) = self.parse_basic_auth(auth_header)?;
// 2. Query database (use block_on for sync context)
let user_record = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(
self.pool.query_one("SELECT * FROM users WHERE username = $1", &[&username])
)
}).ok()?;
// 3. Verify password
if !self.encoder.matches(&password, &user_record.password_hash) {
return None;
}
// 4. Build and return User
Some(User {
username: user_record.username,
password: user_record.password_hash,
roles: user_record.roles.into_iter().collect(),
authorities: user_record.authorities.into_iter().collect(),
})
}
}
Register with SecurityTransform
let db_authenticator = DatabaseAuthenticator::new(pool, encoder);
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(move || db_authenticator.clone())
.config_authorizer(|| /* ... */)
)
Custom Authorizer
Implement custom authorization logic.
Implement the Trait
use actix_security::http::security::config::{Authorizer, AuthorizationResult};
use actix_security::http::security::User;
use actix_web::dev::ServiceRequest;
#[derive(Clone)]
pub struct AbacAuthorizer {
policy_engine: PolicyEngine,
}
impl Authorizer for AbacAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Build policy context
let context = PolicyContext {
subject: user.map(|u| Subject {
id: u.username.clone(),
roles: u.roles.clone(),
attributes: self.get_user_attributes(user),
}),
resource: Resource {
path: req.path().to_string(),
method: req.method().to_string(),
},
environment: Environment {
time: chrono::Utc::now(),
ip: req.peer_addr().map(|a| a.ip()),
},
};
// Evaluate policy
match self.policy_engine.evaluate(&context) {
PolicyDecision::Allow => AuthorizationResult::Granted,
PolicyDecision::Deny => AuthorizationResult::Denied,
PolicyDecision::NotApplicable => {
if user.is_some() {
AuthorizationResult::Denied
} else {
AuthorizationResult::LoginRequired
}
}
}
}
}
Custom Password Encoder
Implement custom password hashing.
Implement the Trait
use actix_security::http::security::PasswordEncoder;
#[derive(Clone)]
pub struct BcryptEncoder {
cost: u32,
}
impl PasswordEncoder for BcryptEncoder {
fn encode(&self, raw_password: &str) -> String {
bcrypt::hash(raw_password, self.cost).unwrap()
}
fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
bcrypt::verify(raw_password, encoded_password).unwrap_or(false)
}
}
Use with DelegatingPasswordEncoder
let encoder = DelegatingPasswordEncoder::new()
.with_encoder("bcrypt", Box::new(BcryptEncoder::new(12)))
.with_encoder("argon2", Box::new(Argon2PasswordEncoder::new()))
.default_encoder("argon2");
Custom Expression Functions
Add domain-specific expression functions.
Implement ExpressionRoot
use actix_security::http::security::expression::ExpressionRoot;
use actix_security::http::security::User;
#[derive(Clone)]
pub struct TenantExpressionRoot {
tenant_service: TenantService,
}
impl ExpressionRoot for TenantExpressionRoot {
fn evaluate_function(
&self,
name: &str,
args: &[String],
user: Option<&User>,
) -> Option<bool> {
match name {
"belongsToTenant" => {
let tenant_id = args.get(0)?;
let user = user?;
Some(self.tenant_service.user_belongs_to(&user.username, tenant_id))
}
"isTenantAdmin" => {
let tenant_id = args.get(0)?;
let user = user?;
Some(self.tenant_service.is_admin(&user.username, tenant_id))
}
"hasTenantPermission" => {
let tenant_id = args.get(0)?;
let permission = args.get(1)?;
let user = user?;
Some(self.tenant_service.has_permission(
&user.username, tenant_id, permission
))
}
_ => None, // Let default handle unknown functions
}
}
}
Use in Expressions
#[pre_authorize("belongsToTenant('acme')")]
async fn tenant_resource() {}
#[pre_authorize("isTenantAdmin('acme') OR hasRole('SUPER_ADMIN')")]
async fn tenant_admin() {}
#[pre_authorize("hasTenantPermission('acme', 'billing:manage')")]
async fn billing() {}
Combining Extensions
// Custom components
let db_authenticator = DatabaseAuthenticator::new(pool.clone(), encoder.clone());
let abac_authorizer = AbacAuthorizer::new(policy_engine);
let tenant_root = TenantExpressionRoot::new(tenant_service);
// Create app
App::new()
.wrap(SecurityHeaders::strict())
.wrap(
SecurityTransform::new()
.config_authenticator(move || db_authenticator.clone())
.config_authorizer(move || abac_authorizer.clone())
)
.app_data(web::Data::new(tenant_root))
.service(/* ... */)
Best Practices
1. Clone Efficiently
All extension traits require Clone. Use Arc for shared state:
#[derive(Clone)]
pub struct MyAuthenticator {
pool: Arc<PgPool>, // Shared connection pool
cache: Arc<RwLock<Cache>>, // Shared cache
}
2. Handle Errors Gracefully
Return None or default values on error:
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Return None on any error
let header = req.headers().get("Authorization")?;
let token = header.to_str().ok()?;
self.validate_token(token).ok()
}
3. Log Security Events
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
let result = self.do_authorize(user, req);
match &result {
AuthorizationResult::Denied => {
log::warn!(
"Access denied: user={:?} path={} method={}",
user.map(|u| &u.username),
req.path(),
req.method()
);
}
AuthorizationResult::Granted => {
log::debug!("Access granted: user={:?}", user.map(|u| &u.username));
}
_ => {}
}
result
}
4. Test Extensions
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_custom_authenticator() {
let auth = TestAuthenticator::new();
let req = test_request_with_header("Authorization", "Basic dGVzdDp0ZXN0");
let user = auth.authenticate(&req);
assert!(user.is_some());
assert_eq!(user.unwrap().username, "test");
}
#[test]
fn test_custom_expression() {
let root = TenantExpressionRoot::new(mock_tenant_service());
let user = test_user();
let result = root.evaluate_function(
"belongsToTenant",
&["acme".to_string()],
Some(&user),
);
assert_eq!(result, Some(true));
}
}
Testing
Best practices for testing secured Actix Web applications.
Test Setup
Create Test Helpers
// tests/common/mod.rs
use actix_security::http::security::{
AuthenticationManager, AuthorizationManager, Argon2PasswordEncoder,
PasswordEncoder, User, Access,
};
use actix_security::http::security::web::{MemoryAuthenticator, RequestMatcherAuthorizer};
use base64::prelude::*;
/// Create test authenticator with predefined users.
pub fn test_authenticator() -> MemoryAuthenticator {
let encoder = Argon2PasswordEncoder::new();
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into(), "USER".into()])
.authorities(&["users:read".into(), "users:write".into()])
)
.with_user(
User::with_encoded_password("user", encoder.encode("user"))
.roles(&["USER".into()])
.authorities(&["users:read".into()])
)
.with_user(
User::with_encoded_password("guest", encoder.encode("guest"))
.roles(&["GUEST".into()])
)
}
/// Create test authorizer.
pub fn test_authorizer() -> RequestMatcherAuthorizer {
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authenticated())
}
/// Create Basic Auth header value.
pub fn basic_auth(username: &str, password: &str) -> String {
let credentials = format!("{}:{}", username, password);
format!("Basic {}", BASE64_STANDARD.encode(credentials))
}
Create Test App
use actix_web::{test, App};
use actix_security::http::security::middleware::SecurityTransform;
pub async fn create_test_app() -> impl actix_web::dev::Service<
actix_http::Request,
Response = actix_web::dev::ServiceResponse,
Error = actix_web::Error,
> {
test::init_service(
App::new()
.wrap(
SecurityTransform::new()
.config_authenticator(test_authenticator)
.config_authorizer(test_authorizer)
)
.service(your_handlers)
)
.await
}
Testing Authentication
Test Successful Authentication
#[actix_web::test]
async fn test_authentication_success() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/api/resource")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
Test Invalid Credentials
#[actix_web::test]
async fn test_authentication_invalid_password() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/api/resource")
.insert_header(("Authorization", basic_auth("user", "wrong_password")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
Test Missing Authentication
#[actix_web::test]
async fn test_authentication_missing() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/api/resource")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
Testing Authorization
Test Role-Based Access
#[actix_web::test]
async fn test_admin_can_access_admin_endpoint() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/admin/dashboard")
.insert_header(("Authorization", basic_auth("admin", "admin")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_user_cannot_access_admin_endpoint() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/admin/dashboard")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
Test Authority-Based Access
#[actix_web::test]
async fn test_user_with_authority_can_access() {
let app = create_test_app().await;
// admin has users:write authority
let req = test::TestRequest::post()
.uri("/api/users")
.insert_header(("Authorization", basic_auth("admin", "admin")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_user_without_authority_cannot_access() {
let app = create_test_app().await;
// user doesn't have users:write authority
let req = test::TestRequest::post()
.uri("/api/users")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
Testing Method Security
Test @secured Macro
#[actix_web::test]
async fn test_secured_endpoint_with_required_role() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/secured/admin")
.insert_header(("Authorization", basic_auth("admin", "admin")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_secured_endpoint_without_required_role() {
let app = create_test_app().await;
let req = test::TestRequest::get()
.uri("/secured/admin")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
Test @pre_authorize Expressions
#[actix_web::test]
async fn test_expression_with_and() {
let app = create_test_app().await;
// Endpoint: hasRole('USER') AND hasAuthority('users:read')
// user has both
let req = test::TestRequest::get()
.uri("/expr/user-and-read")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_expression_with_or() {
let app = create_test_app().await;
// Endpoint: hasRole('ADMIN') OR hasAuthority('users:write')
// admin has ADMIN role
let req = test::TestRequest::get()
.uri("/expr/admin-or-write")
.insert_header(("Authorization", basic_auth("admin", "admin")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
Testing Security Headers
#[actix_web::test]
async fn test_security_headers_present() {
let app = test::init_service(
App::new()
.wrap(SecurityHeaders::default())
.service(test_endpoint)
).await;
let req = test::TestRequest::get().uri("/test").to_request();
let resp = test::call_service(&app, req).await;
let headers = resp.headers();
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
}
Testing Security Context
#[tokio::test]
async fn test_security_context() {
let user = User::new("test".to_string(), "".to_string())
.roles(&["USER".into()])
.authorities(&["read".into()]);
SecurityContext::run_with(Some(user), async {
assert!(SecurityContext::is_authenticated());
assert!(SecurityContext::has_role("USER"));
assert!(SecurityContext::has_authority("read"));
assert!(!SecurityContext::has_role("ADMIN"));
let current = SecurityContext::get_user().unwrap();
assert_eq!(current.username, "test");
}).await;
}
Integration Test Patterns
Test Matrix
struct TestCase {
name: &'static str,
user: Option<(&'static str, &'static str)>,
path: &'static str,
expected_status: StatusCode,
}
#[actix_web::test]
async fn test_authorization_matrix() {
let app = create_test_app().await;
let test_cases = vec![
TestCase {
name: "admin can access admin endpoint",
user: Some(("admin", "admin")),
path: "/admin/dashboard",
expected_status: StatusCode::OK,
},
TestCase {
name: "user cannot access admin endpoint",
user: Some(("user", "user")),
path: "/admin/dashboard",
expected_status: StatusCode::FORBIDDEN,
},
TestCase {
name: "anonymous cannot access admin endpoint",
user: None,
path: "/admin/dashboard",
expected_status: StatusCode::UNAUTHORIZED,
},
];
for tc in test_cases {
let mut req = test::TestRequest::get().uri(tc.path);
if let Some((username, password)) = tc.user {
req = req.insert_header(("Authorization", basic_auth(username, password)));
}
let resp = test::call_service(&app, req.to_request()).await;
assert_eq!(
resp.status(),
tc.expected_status,
"Failed: {}",
tc.name
);
}
}
Best Practices
- Test all user types - Admin, regular user, guest, anonymous
- Test edge cases - Invalid credentials, missing headers, malformed tokens
- Test both positive and negative cases - Access granted AND denied
- Use descriptive test names - Clear what's being tested
- Keep test helpers DRY - Share common setup code
- Test security headers - Verify they're present and correct
- Test expressions - Cover AND, OR, NOT combinations
API Documentation
Links to the detailed API documentation for each crate.
Crate Documentation
actix-security
Main library providing security middleware, authentication, and authorization.
View API Docs (when published)
Generate locally:
cargo doc -p actix-security --open
Key Types
| Type | Description |
|---|---|
SecurityTransform | Main middleware |
Authenticator | Authentication trait |
Authorizer | Authorization trait |
User | User model |
AuthenticatedUser | Request extractor |
SecurityContext | Current user access |
SecurityHeaders | Security headers middleware |
PasswordEncoder | Password encoding trait |
ExpressionRoot | Custom expression trait |
JwtAuthenticator | JWT authentication (feature: jwt) |
JwtConfig | JWT configuration (feature: jwt) |
SessionAuthenticator | Session authentication (feature: session) |
SessionConfig | Session configuration (feature: session) |
Procedural Macros
Procedural macros are included in actix-security when the macros feature is enabled (default).
For the underlying implementation, see the
actix-security-codegencrate.
Macros
| Macro | Description |
|---|---|
#[secured] | Role-based access |
#[pre_authorize] | Expression-based access |
#[permit_all] | Public endpoints |
#[deny_all] | Blocked endpoints |
#[roles_allowed] | Java EE style |
Module Reference
actix_security::http::security
Main security module.
use actix_security::http::security::{
// Traits
Authenticator,
Authorizer,
PasswordEncoder,
// Types
User,
AuthenticatedUser,
OptionalUser,
SecurityContext,
SecurityHeaders,
// Implementations
MemoryAuthenticator,
RequestMatcherAuthorizer,
Access,
Argon2PasswordEncoder,
NoOpPasswordEncoder,
DelegatingPasswordEncoder,
// Factory methods
AuthenticationManager,
AuthorizationManager,
};
actix_security::http::security::middleware
Middleware types.
use actix_security::http::security::middleware::SecurityTransform;
actix_security::http::security::expression
Expression language types.
use actix_security::http::security::expression::{
Expression,
BinaryOp,
UnaryOp,
ExpressionEvaluator,
ExpressionRoot,
DefaultExpressionRoot,
SecurityExpression,
ParseError,
};
actix_security::http::security::jwt
JWT authentication types (feature: jwt).
use actix_security::http::security::jwt::{
JwtAuthenticator,
JwtConfig,
JwtTokenService,
Claims,
JwtError,
Algorithm,
};
actix_security::http::security::session
Session authentication types (feature: session).
use actix_security::http::security::session::{
SessionAuthenticator,
SessionConfig,
SessionUser,
SessionLoginService,
SessionError,
};
actix_security::http::security::headers
Security headers types.
use actix_security::http::security::headers::{
SecurityHeaders,
FrameOptions,
ReferrerPolicy,
};
actix_security::http::error
Error types.
use actix_security::http::error::AuthError;
Quick Reference
Creating Users
// With encoded password
User::with_encoded_password("username", encoder.encode("password"))
.roles(&["ROLE".into()])
.authorities(&["auth".into()])
// Plain (for testing only)
User::new("username".to_string(), "password".to_string())
.roles(&["ROLE".into()])
Configuring Authentication
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder)
.with_user(user)
Configuring Authorization
AuthorizationManager::request_matcher()
.login_url("/login")
.http_basic()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authenticated())
Access Configuration
Access::new()
.roles(vec!["ROLE1", "ROLE2"])
.authorities(vec!["auth1", "auth2"])
.authenticated()
.deny_all()
Security Headers
SecurityHeaders::new()
.frame_options(FrameOptions::Deny)
.content_security_policy("default-src 'self'")
.hsts(true, 31536000)
.referrer_policy(ReferrerPolicy::NoReferrer)
SecurityHeaders::default() // Safe defaults
SecurityHeaders::strict() // Maximum security
Password Encoding
let encoder = Argon2PasswordEncoder::new();
let encoded = encoder.encode("password");
let matches = encoder.matches("password", &encoded);
Security Context
SecurityContext::get_user() // Option<User>
SecurityContext::has_role("ROLE") // bool
SecurityContext::has_authority("auth") // bool
SecurityContext::is_authenticated() // bool
Feature Flags
actix-security
| Feature | Default | Description |
|---|---|---|
argon2 | Yes | Argon2 password encoder |
http-basic | Yes | HTTP Basic authentication |
jwt | No | JWT authentication |
session | No | Session-based authentication |
full | No | All features enabled |
# Default features (argon2, http-basic)
actix-security = "0.1"
# Minimal
actix-security = { version = "0.1", default-features = false }
# With JWT
actix-security = { version = "0.1", features = ["jwt"] }
# With Session
actix-security = { version = "0.1", features = ["session"] }
# All features
actix-security = { version = "0.1", features = ["full"] }
Spring Security Comparison
A comprehensive mapping between Spring Security and Actix Security concepts.
Annotations / Macros
| Spring Security | Actix Security | Notes |
|---|---|---|
@Secured("ROLE_ADMIN") | #[secured("ADMIN")] | No ROLE_ prefix in Actix |
@PreAuthorize("...") | #[pre_authorize("...")] | Similar expression syntax |
@PermitAll | #[permit_all] | Identical purpose |
@DenyAll | #[deny_all] | Identical purpose |
@RolesAllowed({"A", "B"}) | #[roles_allowed("A", "B")] | Java EE style |
Expression Language
| Spring Security | Actix Security |
|---|---|
hasRole('ADMIN') | hasRole('ADMIN') |
hasAnyRole('A', 'B') | hasAnyRole('A', 'B') |
hasAuthority('read') | hasAuthority('read') |
hasAnyAuthority('a', 'b') | hasAnyAuthority('a', 'b') |
isAuthenticated() | isAuthenticated() |
permitAll() | permitAll() |
denyAll() | denyAll() |
and / && | AND |
or / || | OR |
! / not | NOT |
Configuration Classes
Authentication
Spring Security:
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("ADMIN", "USER")
.build());
return manager;
}
Actix Security:
let encoder = Argon2PasswordEncoder::new();
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into(), "USER".into()])
)
Authorization
Spring Security:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
Actix Security:
AuthorizationManager::request_matcher()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authenticated())
// No matcher = permit all
Password Encoding
Spring Security:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// Or delegating
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Actix Security:
let encoder = Argon2PasswordEncoder::new();
// Or delegating
let encoder = DelegatingPasswordEncoder::new()
.with_encoder("argon2", Box::new(Argon2PasswordEncoder::new()))
.with_encoder("noop", Box::new(NoOpPasswordEncoder::new()))
.default_encoder("argon2");
HTTP Basic
Spring Security:
http.httpBasic(Customizer.withDefaults());
Actix Security:
AuthorizationManager::request_matcher()
.http_basic()
Security Headers
Spring Security:
http.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'"))
);
Actix Security:
SecurityHeaders::new()
.frame_options(FrameOptions::Deny)
.content_security_policy("default-src 'self'")
Core Interfaces / Traits
| Spring Security | Actix Security |
|---|---|
AuthenticationManager | Authenticator trait |
UserDetailsService | Authenticator.authenticate() |
UserDetails | User |
Authentication | AuthenticatedUser |
AuthorizationManager | Authorizer trait |
SecurityContext | SecurityContext |
PasswordEncoder | PasswordEncoder trait |
SecurityExpressionRoot | ExpressionRoot trait |
Extension Points
Custom Authentication
Spring Security:
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication auth) {
// Custom logic
}
}
Actix Security:
impl Authenticator for CustomAuthenticator {
fn authenticate(&self, req: &ServiceRequest) -> Option<User> {
// Custom logic
}
}
Custom Authorization
Spring Security:
public class CustomAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> auth, RequestAuthorizationContext ctx) {
// Custom logic
}
}
Actix Security:
impl Authorizer for CustomAuthorizer {
fn authorize(&self, user: Option<&User>, req: &ServiceRequest) -> AuthorizationResult {
// Custom logic
}
}
Custom Expression Functions
Spring Security:
public class CustomSecurityExpressionRoot extends SecurityExpressionRoot {
public boolean customFunction() {
return true;
}
}
Actix Security:
impl ExpressionRoot for CustomRoot {
fn evaluate_function(&self, name: &str, args: &[String], user: Option<&User>) -> Option<bool> {
match name {
"customFunction" => Some(true),
_ => None,
}
}
}
Key Differences
| Aspect | Spring Security | Actix Security |
|---|---|---|
| Role prefix | Adds ROLE_ automatically | No prefix |
| Expression operators | and, or, ! | AND, OR, NOT |
| String quotes | Double quotes " | Single quotes ' |
| Compile-time | Runtime expression parsing | Compile-time parsing |
| Async | Synchronous by default | Async-first |
Migration Checklist
-
Remove
ROLE_prefix from role names -
Change
and/ortoAND/ORin expressions - Change double quotes to single quotes in expressions
-
Replace
@PreAuthorizewith#[pre_authorize] -
Replace
@Securedwith#[secured] -
Replace
BCryptPasswordEncoderwithArgon2PasswordEncoder - Update security configuration to builder pattern
-
Add
AuthenticatedUserparameter to secured handlers
Migration Guide
Migrating from Spring Security
This guide helps Spring Security developers transition to Actix Security.
Step 1: Update Dependencies
Before (Maven/Gradle):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
After (Cargo.toml):
[dependencies]
actix-security = { version = "0.1", features = ["argon2", "http-basic"] }
actix-security = "0.1"
Step 2: Update Annotations
Before (Java):
@Secured("ROLE_ADMIN")
@GetMapping("/admin")
public String admin() { ... }
@PreAuthorize("hasRole('USER') and hasAuthority('posts:write')")
@PostMapping("/posts")
public String createPost() { ... }
@PermitAll
@GetMapping("/public")
public String publicEndpoint() { ... }
After (Rust):
#[secured("ADMIN")] // No ROLE_ prefix
#[get("/admin")]
async fn admin(user: AuthenticatedUser) -> impl Responder { ... }
#[pre_authorize("hasRole('USER') AND hasAuthority('posts:write')")] // AND not and
#[post("/posts")]
async fn create_post(user: AuthenticatedUser) -> impl Responder { ... }
#[permit_all]
#[get("/public")]
async fn public_endpoint() -> impl Responder { ... } // No AuthenticatedUser needed
Step 3: Update Configuration
Before (Java):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
var admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
}
After (Rust):
use actix_security::http::security::{
AuthenticationManager, AuthorizationManager,
Argon2PasswordEncoder, PasswordEncoder, User, Access,
};
use actix_security::http::security::middleware::SecurityTransform;
fn configure_security(encoder: Argon2PasswordEncoder) -> SecurityTransform<...> {
SecurityTransform::new()
.config_authenticator(move || {
AuthenticationManager::in_memory_authentication()
.password_encoder(encoder.clone())
.with_user(
User::with_encoded_password("admin", encoder.encode("admin"))
.roles(&["ADMIN".into()])
)
})
.config_authorizer(|| {
AuthorizationManager::request_matcher()
.http_basic()
.add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
.add_matcher("/api/.*", Access::new().authenticated())
})
}
Step 4: Update Expression Syntax
| Spring Security | Actix Security |
|---|---|
hasRole("ADMIN") | hasRole('ADMIN') |
hasRole('ADMIN') | hasRole('ADMIN') |
expr1 and expr2 | expr1 AND expr2 |
expr1 or expr2 | expr1 OR expr2 |
!expr | NOT expr |
not expr | NOT expr |
Step 5: Update Custom Expressions
Before (Java):
public class CustomSecurityExpressionRoot extends SecurityExpressionRoot
implements MethodSecurityExpressionOperations {
public boolean isPremium() {
return premiumService.isPremium(getAuthentication().getName());
}
}
After (Rust):
impl ExpressionRoot for CustomExpressionRoot {
fn evaluate_function(
&self,
name: &str,
args: &[String],
user: Option<&User>,
) -> Option<bool> {
match name {
"isPremium" => {
let user = user?;
Some(self.premium_service.is_premium(&user.username))
}
_ => None,
}
}
}
Step 6: Update Password Encoding
Before (Java):
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
After (Rust):
let encoder = Argon2PasswordEncoder::new();
Step 7: Update Security Context Access
Before (Java):
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
// Admin logic
}
After (Rust):
if let Some(user) = SecurityContext::get_user() {
let username = &user.username;
if SecurityContext::has_role("ADMIN") {
// Admin logic
}
}
Common Migration Issues
Issue: ROLE_ prefix not working
Problem: #[secured("ROLE_ADMIN")] doesn't match users with "ADMIN" role.
Solution: Actix Security doesn't use the ROLE_ prefix. Use #[secured("ADMIN")] instead.
Issue: Expression operators not recognized
Problem: #[pre_authorize("hasRole('ADMIN') && hasAuthority('write')")] fails.
Solution: Use AND/OR/NOT instead of &&/||/!.
Issue: Double quotes in expressions
Problem: #[pre_authorize("hasRole(\"ADMIN\")")] fails.
Solution: Use single quotes: #[pre_authorize("hasRole('ADMIN')")].
Issue: Missing AuthenticatedUser
Problem: Handler doesn't compile with security macro.
Solution: Add AuthenticatedUser parameter to secured handlers:
#[secured("USER")]
#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder { ... }
Issue: permit_all still requires auth
Problem: #[permit_all] endpoint returns 401.
Solution: Check URL-based authorization rules. If your URL matcher requires authentication for that path, remove the matcher or add an exception.
Testing Migration
#[actix_web::test]
async fn test_migrated_security() {
let app = create_test_app().await;
// Test role check
let req = test::TestRequest::get()
.uri("/admin")
.insert_header(("Authorization", basic_auth("admin", "admin")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
// Test expression
let req = test::TestRequest::post()
.uri("/posts")
.insert_header(("Authorization", basic_auth("user", "user")))
.to_request();
let resp = test::call_service(&app, req).await;
// Check expected status based on user's roles/authorities
}