Vix.cpp v2.6.0 is here Read the blog
Skip to content

RBAC

This example shows how to protect Vix routes with role-based and permission-based authorization.

RBAC means:

txt
Role-Based Access Control

Use RBAC when a route should be accessible only to users with specific roles or permissions.

Examples:

txt
admin
seller
moderator
products:write
orders:read
users:delete

JWT authentication answers:

txt
who is the caller?

RBAC authorization answers:

txt
what is the caller allowed to do?

What this example builds

The app exposes:

txt
GET /
GET /api/public
GET /api/me
GET /api/admin
GET /api/products/write

Behavior:

txt
GET /api/public
  public route

GET /api/me
  requires valid JWT

GET /api/admin
  requires valid JWT and role admin

GET /api/products/write
  requires valid JWT and permission products:write

The middleware order is:

txt
JWT
  validates the token and stores JwtClaims

RBAC context
  builds Authz from JwtClaims

require_role or require_perm
  enforces authorization

handler
  runs only if authorization passed

For Vix v2.6.2 and newer, use:

cpp
#include <vix/middleware.hpp>

For older v2.6.0 or v2.6.1, App integration headers may need explicit includes:

cpp
#include <vix/middleware/app/adapter.hpp>
#include <vix/middleware/auth/jwt.hpp>
#include <vix/middleware/auth/rbac.hpp>

This example uses the modern public entry point.

Project structure

Create:

txt
auth_rbac_demo/
└── auth_rbac.cpp

Create the file:

bash
mkdir auth_rbac_demo
cd auth_rbac_demo
touch auth_rbac.cpp

Source

Open:

txt
auth_rbac.cpp

Add:

cpp
#include <iostream>
#include <string>

#include <vix.hpp>
#include <vix/middleware.hpp>

using namespace vix;

static const std::string kAdminToken =
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
  "eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiYWRtaW4iXSwicGVybXMiOlsicHJvZHVjdHM6d3JpdGUiLCJvcmRlcnM6cmVhZCJdfQ."
  "w1y3nA2F1kq0oJ0x8wWc5wQx8zF4h2d6V7mYp0jYk3Q";

static const std::string kUserToken =
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
  "eyJzdWIiOiJ1c2VyNDU2Iiwicm9sZXMiOlsidXNlciJdLCJwZXJtcyI6WyJvcmRlcnM6cmVhZCJdfQ."
  "1lcu1TtxMHllkoYc5mlneK7vKLLQDe0PxUtcfPG4XVM";

static vix::App::Middleware jwt_middleware()
{
  middleware::auth::JwtOptions options;

  options.secret = "dev_secret";
  options.verify_exp = false;

  return middleware::app::adapt_ctx(
    middleware::auth::jwt(options)
  );
}

static vix::App::Middleware rbac_context_middleware()
{
  middleware::auth::RbacOptions options;

  options.require_auth = true;
  options.use_resolver = false;

  return middleware::app::adapt_ctx(
    middleware::auth::rbac_context(options)
  );
}

static vix::App::Middleware require_admin_role()
{
  return middleware::app::adapt_ctx(
    middleware::auth::require_role("admin")
  );
}

static vix::App::Middleware require_products_write()
{
  return middleware::app::adapt_ctx(
    middleware::auth::require_perm("products:write")
  );
}

static void install_common_middleware(App &app)
{
  app.use("/api", middleware::app::request_id_dev());
  app.use("/api", middleware::app::timing_dev());
  app.use("/api", middleware::app::security_headers_dev());
}

static void install_auth_middleware(App &app)
{
  app.use("/api/me", jwt_middleware());
  app.use("/api/me", rbac_context_middleware());

  app.use("/api/admin", jwt_middleware());
  app.use("/api/admin", rbac_context_middleware());
  app.use("/api/admin", require_admin_role());

  app.use("/api/products/write", jwt_middleware());
  app.use("/api/products/write", rbac_context_middleware());
  app.use("/api/products/write", require_products_write());
}

static void register_routes(App &app)
{
  app.get("/", [](Request &, Response &res)
  {
    res.send(
      "RBAC auth example\n"
      "\n"
      "Public:\n"
      "  curl -i http://127.0.0.1:8080/api/public\n"
      "\n"
      "Protected:\n"
      "  curl -i http://127.0.0.1:8080/api/me\n"
      "  curl -i -H \"Authorization: Bearer <TOKEN>\" http://127.0.0.1:8080/api/me\n"
      "\n"
      "Admin:\n"
      "  curl -i -H \"Authorization: Bearer <ADMIN_TOKEN>\" http://127.0.0.1:8080/api/admin\n"
      "\n"
      "Permission:\n"
      "  curl -i -H \"Authorization: Bearer <ADMIN_TOKEN>\" http://127.0.0.1:8080/api/products/write\n"
    );
  });

  app.get("/api/public", [](Request &, Response &res)
  {
    res.json({
      "ok", true,
      "message", "public route"
    });
  });

  app.get("/api/me", [](Request &req, Response &res)
  {
    auto &authz =
      req.state<middleware::auth::Authz>();

    res.json({
      "ok", true,
      "subject", authz.subject,
      "has_admin", authz.has_role("admin"),
      "can_write_products", authz.has_perm("products:write")
    });
  });

  app.get("/api/admin", [](Request &req, Response &res)
  {
    auto &authz =
      req.state<middleware::auth::Authz>();

    res.json({
      "ok", true,
      "message", "admin route accepted",
      "subject", authz.subject,
      "has_admin", authz.has_role("admin")
    });
  });

  app.get("/api/products/write", [](Request &req, Response &res)
  {
    auto &authz =
      req.state<middleware::auth::Authz>();

    res.json({
      "ok", true,
      "message", "products:write permission accepted",
      "subject", authz.subject,
      "can_write_products", authz.has_perm("products:write")
    });
  });
}

static void print_help()
{
  std::cout
    << "Vix RBAC auth example running:\n"
    << "  http://127.0.0.1:8080/\n"
    << "  http://127.0.0.1:8080/api/public\n"
    << "  http://127.0.0.1:8080/api/me\n"
    << "  http://127.0.0.1:8080/api/admin\n"
    << "  http://127.0.0.1:8080/api/products/write\n\n"
    << "Admin token:\n"
    << "  " << kAdminToken << "\n\n"
    << "User token:\n"
    << "  " << kUserToken << "\n\n";
}

int main()
{
  App app;

  install_common_middleware(app);
  install_auth_middleware(app);
  register_routes(app);

  print_help();

  app.run(8080);
  return 0;
}

Run it

Run:

bash
vix run auth_rbac.cpp

The server listens on:

txt
http://127.0.0.1:8080

Test the public route

bash
curl -i http://127.0.0.1:8080/api/public

Expected body:

json
{
  "ok": true,
  "message": "public route"
}

This route does not require authentication.

Set tokens

Admin token:

bash
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiYWRtaW4iXSwicGVybXMiOlsicHJvZHVjdHM6d3JpdGUiLCJvcmRlcnM6cmVhZCJdfQ.w1y3nA2F1kq0oJ0x8wWc5wQx8zF4h2d6V7mYp0jYk3Q"

User token:

bash
USER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyNDU2Iiwicm9sZXMiOlsidXNlciJdLCJwZXJtcyI6WyJvcmRlcnM6cmVhZCJdfQ.1lcu1TtxMHllkoYc5mlneK7vKLLQDe0PxUtcfPG4XVM"

The admin token contains:

json
{
  "sub": "user123",
  "roles": ["admin"],
  "perms": ["products:write", "orders:read"]
}

The user token contains:

json
{
  "sub": "user456",
  "roles": ["user"],
  "perms": ["orders:read"]
}

Test missing token

bash
curl -i http://127.0.0.1:8080/api/me

Expected status:

txt
401 Unauthorized

The request has no valid JWT, so RBAC cannot build an authorization context.

Test authenticated user

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/me

Expected body shape:

json
{
  "ok": true,
  "subject": "user456",
  "has_admin": false,
  "can_write_products": false
}

This route only requires a valid JWT.

It does not require a specific role or permission.

Test admin route with admin token

bash
curl -i \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/admin

Expected body shape:

json
{
  "ok": true,
  "message": "admin route accepted",
  "subject": "user123",
  "has_admin": true
}

The admin token has:

txt
role = admin

so the request is accepted.

Test admin route with normal user token

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/admin

Expected status:

txt
403 Forbidden

The user is authenticated, but not authorized.

That is the difference between:

txt
401 Unauthorized
  authentication failed or missing

403 Forbidden
  authentication passed, authorization failed

Test permission route with admin token

bash
curl -i \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/products/write

Expected body shape:

json
{
  "ok": true,
  "message": "products:write permission accepted",
  "subject": "user123",
  "can_write_products": true
}

The admin token has:

txt
perm = products:write

so the request is accepted.

Test permission route with normal user token

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/products/write

Expected status:

txt
403 Forbidden

The user token has:

txt
orders:read

but not:

txt
products:write

so the request is rejected.

How JWT and RBAC work together

JWT validates the token:

cpp
app.use("/api/admin", jwt_middleware());

RBAC builds the authorization context from JWT claims:

cpp
app.use("/api/admin", rbac_context_middleware());

Then the route requires a role:

cpp
app.use("/api/admin", require_admin_role());

The full chain is:

txt
request
  -> JWT middleware
  -> RBAC context middleware
  -> require_role("admin")
  -> handler

If JWT fails, the handler does not run.

If RBAC fails, the handler does not run.

Build RBAC context

RBAC context is built with:

cpp
middleware::auth::RbacOptions options;

options.require_auth = true;
options.use_resolver = false;

app.use("/api/me", middleware::app::adapt_ctx(
  middleware::auth::rbac_context(options)
));

The middleware reads:

cpp
middleware::auth::JwtClaims

and creates:

cpp
middleware::auth::Authz

The handler can then read:

cpp
auto &authz =
  req.state<middleware::auth::Authz>();

Authz

Authz contains:

cpp
struct Authz
{
  std::string subject;
  std::unordered_set<std::string> roles;
  std::unordered_set<std::string> perms;

  bool has_role(std::string_view r) const;
  bool has_perm(std::string_view p) const;
};

Typical usage:

cpp
auto &authz =
  req.state<middleware::auth::Authz>();

if (authz.has_role("admin"))
{
  // admin logic
}

if (authz.has_perm("products:write"))
{
  // write product logic
}

Most of the time, prefer middleware checks instead of manual checks inside handlers.

Require one role

Use:

cpp
middleware::auth::require_role("admin")

with App adaptation:

cpp
app.use("/api/admin", middleware::app::adapt_ctx(
  middleware::auth::require_role("admin")
));

This means the route requires:

txt
role = admin

Require one permission

Use:

cpp
middleware::auth::require_perm("products:write")

with App adaptation:

cpp
app.use("/api/products/write", middleware::app::adapt_ctx(
  middleware::auth::require_perm("products:write")
));

This means the route requires:

txt
permission = products:write

Require any role

Use:

cpp
middleware::auth::require_any_role({
  "admin",
  "moderator"
})

This means the caller must have at least one of those roles.

Example:

cpp
app.use("/api/moderation", middleware::app::adapt_ctx(
  middleware::auth::require_any_role({
    "admin",
    "moderator"
  })
));

Require any permission

Use:

cpp
middleware::auth::require_any_perm({
  "products:write",
  "products:manage"
})

This means the caller must have at least one of those permissions.

Require all permissions

Use:

cpp
middleware::auth::require_all_perms({
  "products:write",
  "products:publish"
})

This means the caller must have every listed permission.

Use this for sensitive operations that require several capabilities.

Roles vs permissions

Roles are broad.

Permissions are specific.

Example roles:

txt
admin
seller
support
moderator

Example permissions:

txt
products:write
products:delete
orders:read
orders:refund
users:ban

A practical design is:

txt
roles
  describe who the user is

permissions
  describe what the user can do

For critical routes, permissions are often clearer than roles.

Claims accepted by RBAC

RBAC reads roles from the JWT payload.

Common keys:

txt
roles
role

Permissions can come from:

txt
perms
permissions
scope

A scope string can contain space-separated permissions.

Example payload:

json
{
  "sub": "user123",
  "roles": ["admin"],
  "perms": ["products:write", "orders:read"],
  "scope": "products:write orders:read"
}

Permission resolver

RBAC can also enrich roles and permissions through a resolver.

The interface is:

cpp
struct PermissionResolver
{
  virtual ~PermissionResolver() = default;

  virtual void resolve(
    std::string_view subject,
    std::unordered_set<std::string> &roles_inout,
    std::unordered_set<std::string> &perms_inout) = 0;
};

Use a resolver when permissions should come from a database or external service.

Example use cases:

txt
load user roles from database
load team permissions
load tenant-specific permissions
merge JWT claims with stored permissions

For this example:

cpp
options.use_resolver = false;

That keeps the example simple and fully token-based.

Production secret

The example uses:

txt
dev_secret

Do not hardcode production secrets.

Production shape:

cpp
const std::string jwt_secret =
  cfg.getString("jwt.secret", "");

if (jwt_secret.empty())
{
  throw std::runtime_error("jwt.secret is required");
}

middleware::auth::JwtOptions jwt_options;

jwt_options.secret = jwt_secret;
jwt_options.verify_exp = true;

app.use("/api/private", middleware::app::adapt_ctx(
  middleware::auth::jwt(jwt_options)
));

Use secrets from:

txt
environment variables
secret manager
deployment configuration

Middleware order

The order must be correct.

Good:

cpp
app.use("/api/admin", jwt_middleware());
app.use("/api/admin", rbac_context_middleware());
app.use("/api/admin", require_admin_role());

Wrong:

cpp
app.use("/api/admin", require_admin_role());
app.use("/api/admin", jwt_middleware());
app.use("/api/admin", rbac_context_middleware());

RBAC requirements need the Authz state.

Authz needs JWT claims.

JWT must run first.

401 vs 403

Use this model:

txt
401 Unauthorized
  no valid identity

403 Forbidden
  identity exists, but access is not allowed

Examples:

txt
missing JWT
  401

invalid JWT
  401

valid JWT but missing admin role
  403

valid JWT but missing products:write permission
  403

This makes API behavior easier to debug.

Protect route groups

You can protect a route group:

cpp
app.use("/api/admin", jwt_middleware());
app.use("/api/admin", rbac_context_middleware());
app.use("/api/admin", require_admin_role());

Then these routes are protected:

txt
/api/admin
/api/admin/users
/api/admin/settings

If you want a public route under the same prefix, use a different prefix or install middleware more narrowly.

Combine role and permission

For sensitive routes, you can require both:

cpp
app.use("/api/products/publish", jwt_middleware());
app.use("/api/products/publish", rbac_context_middleware());

app.use("/api/products/publish", middleware::app::adapt_ctx(
  middleware::auth::require_role("seller")
));

app.use("/api/products/publish", middleware::app::adapt_ctx(
  middleware::auth::require_perm("products:publish")
));

This means the caller must have:

txt
role = seller
permission = products:publish

Keep handlers simple

Prefer this:

cpp
app.use("/api/admin", require_admin_role());

app.get("/api/admin", [](Request &, Response &res)
{
  res.json({
    "ok", true
  });
});

over this:

cpp
app.get("/api/admin", [](Request &req, Response &res)
{
  auto &authz = req.state<middleware::auth::Authz>();

  if (!authz.has_role("admin"))
  {
    res.status(403).json({
      "ok", false
    });
    return;
  }

  res.json({
    "ok", true
  });
});

Authorization middleware keeps route handlers focused on business logic.

Common mistakes

Installing RBAC before JWT

Wrong:

cpp
app.use("/admin", rbac_context_middleware());
app.use("/admin", jwt_middleware());

Correct:

cpp
app.use("/admin", jwt_middleware());
app.use("/admin", rbac_context_middleware());

Forgetting RBAC context

Wrong:

cpp
app.use("/admin", jwt_middleware());
app.use("/admin", require_admin_role());

Correct:

cpp
app.use("/admin", jwt_middleware());
app.use("/admin", rbac_context_middleware());
app.use("/admin", require_admin_role());

require_role needs Authz.

Authz is created by rbac_context.

Confusing role and permission

Avoid making roles too specific.

Bad role design:

txt
products_write_user
orders_read_user
users_delete_user

Better:

txt
role
  seller

permissions
  products:write
  products:publish

Trusting client-side UI only

Hiding a button in the frontend is not authorization.

The backend must enforce roles and permissions.

Hardcoding production secrets

Use configuration for secrets.

Do not commit production secrets to the repository.

Production notes

For production RBAC, consider:

txt
short-lived JWTs
expiration verification
issuer validation
audience validation
secret rotation
role and permission design
database-backed permission resolver
audit logs for sensitive actions
tests for protected routes

For enterprise apps, also consider tenant-aware permissions:

txt
tenant:123:products:write
tenant:123:orders:read
tenant:456:products:write

Keep the permission format consistent.

Complete test flow

Run:

bash
vix run auth_rbac.cpp

Set tokens:

bash
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiYWRtaW4iXSwicGVybXMiOlsicHJvZHVjdHM6d3JpdGUiLCJvcmRlcnM6cmVhZCJdfQ.w1y3nA2F1kq0oJ0x8wWc5wQx8zF4h2d6V7mYp0jYk3Q"

USER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyNDU2Iiwicm9sZXMiOlsidXNlciJdLCJwZXJtcyI6WyJvcmRlcnM6cmVhZCJdfQ.1lcu1TtxMHllkoYc5mlneK7vKLLQDe0PxUtcfPG4XVM"

Public:

bash
curl -i http://127.0.0.1:8080/api/public

Missing token:

bash
curl -i http://127.0.0.1:8080/api/me

Authenticated user:

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/me

Admin accepted:

bash
curl -i \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/admin

Admin rejected:

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/admin

Permission accepted:

bash
curl -i \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/products/write

Permission rejected:

bash
curl -i \
  -H "Authorization: Bearer $USER_TOKEN" \
  http://127.0.0.1:8080/api/products/write

Summary

RBAC protects routes after JWT authentication.

Install JWT first:

cpp
app.use("/api/admin", jwt_middleware());

Build authorization context:

cpp
app.use("/api/admin", rbac_context_middleware());

Require a role:

cpp
app.use("/api/admin", middleware::app::adapt_ctx(
  middleware::auth::require_role("admin")
));

Require a permission:

cpp
app.use("/api/products/write", middleware::app::adapt_ctx(
  middleware::auth::require_perm("products:write")
));

The mental model is:

txt
JWT
  proves identity

RBAC context
  extracts roles and permissions

require_role / require_perm
  enforces access

handler
  runs only when access is allowed

Released under the MIT License.