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

MacroSpring EquivalentDescription
#[secured("ADMIN")]@Secured("ROLE_ADMIN")Role-based access
#[pre_authorize(...)]@PreAuthorize(...)Expression-based access
#[permit_all]@PermitAllPublic access
#[deny_all]@DenyAllDeny 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

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?

Installation

Cargo Dependencies

Add the following to your Cargo.toml:

[dependencies]
actix-security = "0.2"

Feature Flags

FeatureDefaultDescription
macrosProcedural macros (#[secured], #[pre_authorize], etc.)
argon2Enables Argon2 password encoding
http-basicEnables HTTP Basic authentication
jwtEnables JWT authentication
sessionEnables Session-based authentication
oauth2Enables OAuth2/OIDC authentication
fullAll 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 SecurityActix WebRust
0.1.x4.x1.70+

Crate Overview

The actix-security crate provides:

Core Features:

  • Security middleware (SecurityTransform)
  • Authentication (MemoryAuthenticator, Authenticator trait)
  • Authorization (RequestMatcherAuthorizer, Authorizer trait)
  • 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

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 SecurityActix Security
AuthenticationManagerAuthenticator trait
UserDetailsServiceAuthenticator::get_user()
UserDetailsUser
AuthenticationAuthenticatedUser
InMemoryUserDetailsManagerMemoryAuthenticator
JwtDecoderJwtAuthenticator
SessionRegistrySessionAuthenticator
ClientRegistrationRepositoryOAuth2ClientRepository
OAuth2UserOAuth2User
PasswordEncoderPasswordEncoder trait

Sections

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

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 argon2 feature 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 NoOpPasswordEncoder in 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 SecurityActix Security
PasswordEncoderPasswordEncoder trait
BCryptPasswordEncoderArgon2PasswordEncoder
NoOpPasswordEncoderNoOpPasswordEncoder
DelegatingPasswordEncoderDelegatingPasswordEncoder

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

  1. Use strong algorithms - Argon2id is currently recommended
  2. Use constant-time comparison - Prevents timing attacks
  3. Salt passwords - Argon2 does this automatically
  4. Tune parameters - Adjust memory/time cost based on your hardware
  5. 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:

AlgorithmDescription
HS256HMAC-SHA256 (default)
HS384HMAC-SHA384
HS512HMAC-SHA512
RS256RSA-SHA256
RS384RSA-SHA384
RS512RSA-SHA512
ES256ECDSA-SHA256
ES384ECDSA-SHA384

Security Best Practices

  1. Use strong secrets - At least 256 bits (32 characters) for HMAC
  2. Set appropriate expiration - Short-lived tokens (15 min - 1 hour)
  3. Use HTTPS - Always transmit tokens over HTTPS
  4. Validate claims - Always validate issuer and audience
  5. Store tokens securely - Never store in localStorage for web apps
  6. 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

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

  1. Use secure cookies - Set cookie_secure(true) in production
  2. Use HTTP-only cookies - Prevents JavaScript access
  3. Set appropriate expiration - Balance security and UX
  4. Regenerate session on login - Prevent session fixation
  5. Use HTTPS - Always use HTTPS in production
  6. 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

ProviderOIDC SupportPKCE Support
GoogleYesYes
GitHubNoNo
MicrosoftYesYes
FacebookNoNo
AppleYesYes
OktaYesYes
Auth0YesYes
KeycloakYesYes

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

  1. Always validate state - Prevents CSRF attacks
  2. Use PKCE - Prevents authorization code interception
  3. Validate nonce for OIDC - Prevents replay attacks
  4. Use HTTPS - Always in production
  5. Validate redirect URIs - Prevent open redirects
  6. Store tokens securely - Use encrypted sessions
  7. 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:

  1. URL-Based Authorization - Configure access rules for URL patterns
  2. 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:

ConceptPurposeExample
RolesCoarse-grained accessADMIN, USER, GUEST
AuthoritiesFine-grained permissionsusers: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 SecurityActix 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 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:

PatternMatches
/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
.*\\.jsonAny 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

  1. Order patterns from specific to general
  2. Use method security for complex rules - URL patterns are best for simple role checks
  3. Don't over-complicate patterns - Keep regex simple and readable
  4. 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

MacroSpring EquivalentUse Case
#[secured("ROLE")]@SecuredSimple role check
#[pre_authorize(...)]@PreAuthorizeExpression-based access
#[permit_all]@PermitAllExplicitly public
#[deny_all]@DenyAllBlock all access
#[roles_allowed("ROLE")]@RolesAllowedJava 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 AuthenticatedUser since 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

AspectRolesAuthorities
GranularityCoarseFine
PurposeUser categoriesSpecific permissions
ExamplesADMIN, USER, GUESTusers:read, posts:write
Use whenGrouping usersControlling 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:

  1. Controlling broad sections of your app

    .add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
  2. User type matters more than specific permission

    #[secured("PREMIUM")]
    #[get("/premium-content")]
    async fn premium_content() -> impl Responder { /* ... */ }
  3. Simple applications with clear user categories

Use Authorities When:

  1. Controlling specific operations

    #[pre_authorize(authority = "users:delete")]
    #[delete("/users/{id}")]
    async fn delete_user() -> impl Responder { /* ... */ }
  2. 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
  3. 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

  1. Use consistent naming

    • Roles: UPPERCASE (ADMIN, USER, MANAGER)
    • Authorities: lowercase:action (users:read, posts:write)
  2. Don't over-engineer

    • Start with roles only
    • Add authorities when you need finer control
  3. 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)
  4. Consider a permission matrix

    Roleusers:readusers:writeusers: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

MacroSpring EquivalentJava EE EquivalentDescription
#[secured]@Secured-Role-based access
#[pre_authorize]@PreAuthorize-Expression-based access
#[permit_all]@PermitAll@PermitAllPublic access
#[deny_all]@DenyAll@DenyAllBlock all access
#[roles_allowed]@Secured@RolesAllowedJava 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 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

FunctionDescription
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

OperatorDescription
ANDBoth conditions must be true
OREither condition can be true
NOTNegates 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/OR instead of and/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 ExpressionRoot trait
  • Familiar syntax - Similar to Spring Security SpEL

Expression Syntax

Functions

FunctionDescriptionExample
hasRole('R')User has role RhasRole('ADMIN')
hasAnyRole('R1', 'R2')User has any of the roleshasAnyRole('ADMIN', 'MANAGER')
hasAuthority('A')User has authority AhasAuthority('users:read')
hasAnyAuthority('A1', 'A2')User has any of the authoritieshasAnyAuthority('read', 'write')
isAuthenticated()User is authenticatedisAuthenticated()
permitAll()Always truepermitAll()
denyAll()Always falsedenyAll()

Operators

OperatorDescriptionExample
ANDBoth must be truehasRole('A') AND hasRole('B')
OREither can be truehasRole('A') OR hasRole('B')
NOTNegationNOT 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/NOT instead of and/or/! (case-insensitive)
  • Use single quotes for strings: 'ADMIN' not "ADMIN"

Sections

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 require AuthenticatedUser.

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

FunctionParametersReturnsDescription
hasRole(role)1 stringboolUser has role
hasAnyRole(r1, r2, ...)1+ stringsboolUser has any role
hasAuthority(auth)1 stringboolUser has authority
hasAnyAuthority(a1, a2, ...)1+ stringsboolUser has any authority
isAuthenticated()noneboolUser is authenticated
permitAll()noneboolAlways true
denyAll()noneboolAlways 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, And all work
  • Function names are case-sensitive: hasRole works, HasRole doesn'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

HeaderRecommended ValueNotes
X-Content-Type-OptionsnosniffAlways enable
X-Frame-OptionsDENY or SAMEORIGINPrevent clickjacking
Content-Security-PolicyApp-specificStart strict, relax as needed
Strict-Transport-Securitymax-age=31536000Only for HTTPS sites
Referrer-Policystrict-origin-when-cross-originBalance privacy/functionality
Permissions-PolicyDisable unused featuresCamera, 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 .await points
  • 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

  1. Request scope only - Context is only available during request handling
  2. No cross-task sharing - Each spawned task needs its own context
  3. 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 SecurityActix Security
CsrfFilterCsrfProtection middleware
CsrfTokenCsrfToken
CsrfTokenRepositoryCsrfTokenRepository trait
HttpSessionCsrfTokenRepositorySessionCsrfTokenRepository
.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 allowed
  • X-RateLimit-Remaining: Requests remaining in current window
  • X-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 SecurityActix Security
Custom filter or Bucket4jRateLimiter middleware
@RateLimiter annotationConfiguration-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 SecurityActix Security
LockedExceptionLockStatus::TemporarilyLocked
AccountStatusUserDetailsCheckercheck_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 SecurityActix Security
AuthenticationEventPublisherAuditLogger
AbstractAuthenticationEventSecurityEvent
@EventListenerSecurityEventHandler 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 identifier
  • expiration: Unix timestamp when token expires
  • signature: 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 SecurityActix Security
RememberMeServicesRememberMeServices
RememberMeConfigurerRememberMeConfig
TokenBasedRememberMeServicesRememberMeServices (default)
PersistentTokenBasedRememberMeServicesPersistentRememberMeServices
PersistentTokenRepositoryPersistentTokenRepository trait
.rememberMe().key().key()
.tokenValiditySeconds().validity_seconds()

Security Considerations

  1. Strong Secret Key: Use a cryptographically random key, at least 32 bytes
  2. Secure Cookies: Enable secure(true) in production (HTTPS only)
  3. HttpOnly: Always use http_only(true) to prevent XSS attacks
  4. Token Rotation: Consider using persistent tokens with rotation for better security
  5. Limited Scope: Remember-me should not grant access to sensitive operations
  6. 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:

  1. The middleware intercepts the request
  2. Constructs an HTTPS URL with the same path and query string
  3. 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 SecurityActix Security
requiresChannel()ChannelSecurityConfig
.requiresSecure().require_secure()
.requiresInsecure().require_insecure()
.anyRequest().requiresSecure().require_secure("/**")
PortMapperPortMapper
ChannelDecisionManagerChannelSecurity 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

  1. Production: Require HTTPS for all routes (/**)
  2. Sensitive Data: Always require HTTPS for login, payments, personal data
  3. HSTS: Combine with HSTS header for additional security
  4. Monitoring: Exclude health check endpoints from HTTPS requirement
  5. API: Return 403 instead of redirect for API endpoints
  6. 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 SecurityActix Security
LdapAuthenticationProviderLdapAuthenticator
LdapContextSourceLdapConfig
LdapUserSearch.user_search_filter()
DefaultLdapAuthoritiesPopulator.group_role_mapping()
ActiveDirectoryLdapAuthenticationProviderLdapConfig::active_directory()

Best Practices

  1. Use TLS: Connect using ldaps:// or STARTTLS for production
  2. Service Account: Use a dedicated service account for binding
  3. Principle of Least Privilege: Service account should only have read access
  4. Connection Pooling: Consider connection pooling for high-traffic applications
  5. 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 SecurityActix Security
Saml2LoginConfigurerSamlConfig
RelyingPartyRegistrationSamlConfig builder methods
Saml2AuthenticationRequestResolvercreate_authn_request()
OpenSaml4AuthenticationProviderSamlAuthenticator
Saml2MetadataFiltergenerate_sp_metadata()

Security Considerations

  1. Use HTTPS: Always use HTTPS for ACS URL in production
  2. Validate Signatures: Never disable signature validation
  3. Certificate Management: Keep IdP certificates up to date
  4. Clock Synchronization: Ensure servers have synchronized time
  5. 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 SecurityService for 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 SecurityActix Security
SecurityFilterChainSecurityTransform
AuthenticationManagerAuthenticator trait
AuthorizationManagerAuthorizer trait
UserDetailsUser
AuthenticationAuthenticatedUser
SecurityContextSecurityContext
MethodSecurityExpressionRootExpressionRoot trait

Extensibility Points

  1. Custom Authenticator - Implement Authenticator trait
  2. Custom Authorizer - Implement Authorizer trait
  3. Custom Password Encoder - Implement PasswordEncoder trait
  4. Custom Expressions - Implement ExpressionRoot trait

Design Principles

  1. Compile-time safety - Catch errors at compile time
  2. Zero-cost abstractions - No runtime overhead for unused features
  3. Explicit over implicit - Clear, readable security configuration
  4. 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 PointPurposeTrait/Type
AuthenticationCustom user extractionAuthenticator
AuthorizationCustom access controlAuthorizer
Password EncodingCustom hashingPasswordEncoder
ExpressionsCustom functionsExpressionRoot

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

  1. Test all user types - Admin, regular user, guest, anonymous
  2. Test edge cases - Invalid credentials, missing headers, malformed tokens
  3. Test both positive and negative cases - Access granted AND denied
  4. Use descriptive test names - Clear what's being tested
  5. Keep test helpers DRY - Share common setup code
  6. Test security headers - Verify they're present and correct
  7. 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

TypeDescription
SecurityTransformMain middleware
AuthenticatorAuthentication trait
AuthorizerAuthorization trait
UserUser model
AuthenticatedUserRequest extractor
SecurityContextCurrent user access
SecurityHeadersSecurity headers middleware
PasswordEncoderPassword encoding trait
ExpressionRootCustom expression trait
JwtAuthenticatorJWT authentication (feature: jwt)
JwtConfigJWT configuration (feature: jwt)
SessionAuthenticatorSession authentication (feature: session)
SessionConfigSession 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-codegen crate.

Macros

MacroDescription
#[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

FeatureDefaultDescription
argon2YesArgon2 password encoder
http-basicYesHTTP Basic authentication
jwtNoJWT authentication
sessionNoSession-based authentication
fullNoAll 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 SecurityActix SecurityNotes
@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 SecurityActix 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
! / notNOT

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 SecurityActix Security
AuthenticationManagerAuthenticator trait
UserDetailsServiceAuthenticator.authenticate()
UserDetailsUser
AuthenticationAuthenticatedUser
AuthorizationManagerAuthorizer trait
SecurityContextSecurityContext
PasswordEncoderPasswordEncoder trait
SecurityExpressionRootExpressionRoot 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

AspectSpring SecurityActix Security
Role prefixAdds ROLE_ automaticallyNo prefix
Expression operatorsand, or, !AND, OR, NOT
String quotesDouble quotes "Single quotes '
Compile-timeRuntime expression parsingCompile-time parsing
AsyncSynchronous by defaultAsync-first

Migration Checklist

  • Remove ROLE_ prefix from role names
  • Change and/or to AND/OR in expressions
  • Change double quotes to single quotes in expressions
  • Replace @PreAuthorize with #[pre_authorize]
  • Replace @Secured with #[secured]
  • Replace BCryptPasswordEncoder with Argon2PasswordEncoder
  • Update security configuration to builder pattern
  • Add AuthenticatedUser parameter 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 SecurityActix Security
hasRole("ADMIN")hasRole('ADMIN')
hasRole('ADMIN')hasRole('ADMIN')
expr1 and expr2expr1 AND expr2
expr1 or expr2expr1 OR expr2
!exprNOT expr
not exprNOT 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
}