Azure Static Web Apps: Custom Roles with rolesSource and Hono

AzureHonoTypeScript

The Problem

Azure Static Web Apps (SWA) with Easy Auth only provides two built-in roles by default: anonymous and authenticated. If you want route-based access control via staticwebapp.config.json, you'll quickly hit a wall — because Entra ID app roles like admin or user do not automatically appear in userRoles.

{
  "routes": [
    { "route": "/api/*", "allowedRoles": ["admin"] }
  ]
}

This does not work out of the box. SWA doesn't know about admin because it simply ignores Entra ID app roles. Every authenticated user has the same permissions — regardless of the roles defined in the App Registration.

The Solution: rolesSource

SWA offers a poorly documented mechanism called rolesSource. The idea: during login, SWA internally calls an API endpoint that returns an array of roles. These are added to the clientPrincipal and become available in userRoles.

How the Flow Works

User logs in
  → SWA authenticates via Entra ID
  → SWA internally calls POST /api/roles
  → Endpoint returns { roles: ["admin", "user"] }
  → SWA adds roles to clientPrincipal
  → userRoles now contains: ["anonymous", "authenticated", "admin", "user"]

The Configuration

In staticwebapp.config.json, rolesSource is defined under auth:

{
  "auth": {
    "rolesSource": "/api/roles",
    "identityProviders": {
      "azureActiveDirectory": {
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/{tenant-id}/v2.0",
          "clientIdSettingName": "AZURE_CLIENT_ID"
        }
      }
    }
  }
}

The Endpoint

During login, SWA sends a POST request to the configured endpoint. The body looks like this:

{
  "identityProvider": "aad",
  "userId": "abc123...",
  "userDetails": "max.mustermann@example.com",
  "claims": [
    { "typ": "roles", "val": "admin" },
    { "typ": "roles", "val": "user" },
    { "typ": "http://schemas.microsoft.com/identity/claims/objectidentifier", "val": "..." }
  ]
}

The Entra ID app roles are contained as claims with typ: "roles" in the array. The endpoint needs to extract them and return them as { roles: string[] }.

In our case using Hono on Azure Functions:

export function registerRolesRoutes(app: OpenAPIHono) {
  app.post('/roles', async (c) => {
    try {
      const body = await c.req.json();
      const claims: Array<{ typ: string; val: string }> = body?.claims ?? [];
      const roles = claims
        .filter((claim) => claim.typ === 'roles')
        .map((claim) => claim.val);
 
      return c.json({ roles });
    } catch {
      return c.json({ roles: [] });
    }
  });
}

The Pitfalls

This is where it gets interesting. All of this is documented somewhere in the Microsoft docs, but the combination of pitfalls cost us several hours of debugging.

1. No clientPrincipal Wrapper

This is the biggest gotcha. If you've worked with SWA Easy Auth, you know the clientPrincipal object from the x-ms-client-principal header. So you'd expect the rolesSource request to use the same format:

// What you expect
{
  "clientPrincipal": {
    "identityProvider": "aad",
    "userId": "...",
    "claims": [...]
  }
}
 
// What you actually get
{
  "identityProvider": "aad",
  "userId": "...",
  "claims": [...]
}

SWA sends the fields directly in the body root, without a wrapper. If you access body.clientPrincipal.claims, you get undefined — and therefore no roles.

2. Route Order in staticwebapp.config.json

SWA evaluates routes top to bottom. The rolesSource call happens during the login flow — at this point, the user is not yet authenticated. SWA sends the request as an internal, unauthenticated call.

If /api/* comes before /api/roles and requires authenticated or a custom role, the rolesSource request gets blocked:

// Wrong: /api/* catches the request
{
  "routes": [
    { "route": "/api/*", "allowedRoles": ["authenticated"] },
    { "route": "/api/roles", "allowedRoles": ["anonymous"] }
  ]
}
 
// Correct: /api/roles comes BEFORE the catch-all
{
  "routes": [
    { "route": "/api/roles", "allowedRoles": ["anonymous"] },
    { "route": "/api/*", "allowedRoles": ["authenticated"] }
  ]
}

The insidious part: there is no error message. The login still works — but the custom roles are silently missing from the token.

3. @hono/zod-openapi Is Incompatible

If you use Hono with @hono/zod-openapi for type-safe API routes, you might try defining the rolesSource endpoint as an OpenAPI route:

// This doesn't work
const route = createRoute({
  method: 'post',
  path: '/roles',
  request: {
    body: {
      content: { 'application/json': { schema: rolesRequestSchema } }
    }
  },
  responses: { 200: { ... } }
});

The internal SWA request fails the zod-openapi validation. Whether it's the Content-Type header, the body structure, or something else — the request never reaches the handler.

The solution: use a plain Hono handler with app.post(). No schema validation, no OpenAPI. The endpoint is only reachable internally by SWA anyway.

4. Not Testable Externally

The rolesSource endpoint is only reachable during the SWA login flow. If you try to call it directly via curl or Postman, you get 404 — not 401, not 403, but 404. SWA doesn't even route external requests to the backend.

This makes debugging harder. To verify whether the endpoint works, you actually have to log in and then inspect the roles in the clientPrincipal (e.g., via /.auth/me).

5. Failure = Silent Degradation

If the rolesSource endpoint throws an error or returns an invalid format, the login does not fail. The user is still logged in — just without custom roles. They only have anonymous and authenticated.

That's why our endpoint returns an empty array on error instead of throwing a 500:

catch {
  return c.json({ roles: [] });
}

This is intentional behavior: better to log in without custom roles than to block the login entirely.

Prerequisite: Entra ID App Roles

For all of this to work, the roles need to exist in the Entra ID App Registration:

  1. App Registration → App roles → Create role (e.g., admin, Value: admin)
  2. Enterprise Application → Users and groups → Assign users/groups to the role
  3. The App Registration must be the SWA that is also configured as the Identity Provider in staticwebapp.config.json

Summary

| Topic | Detail | |---|---| | Configuration | auth.rolesSource: "/api/roles" in staticwebapp.config.json | | Payload | Fields directly in body, no clientPrincipal wrapper | | Role claims | Filter claims array by typ: "roles" | | Route order | /api/roles with anonymous before /api/* | | Framework | Plain app.post(), no zod-openapi | | Testability | Only via the actual login flow, not directly callable | | Error behavior | Silent degradation — login works, roles are missing |

The Microsoft documentation describes the mechanism, but the combination of missing wrapper, route order, and framework incompatibility can only be discovered through trial and error. Hopefully this article saves the next developer a few hours of debugging.


References

All Articles