Microsoft Entra ID

Secure Java Tomcat apps using roles and role claims

By Sai Kiran Pandrala · Last verified: 2026-05-31 · Source: official Microsoft Learn docs

At a glance
Product familyMicrosoft Entra ID
Document sourceAzure Developer Java Identity
Guide typeProcedure Guide
Skill levelIntermediate to advanced
Time15 - 60 minutes depending on environment

This guide covers Secure Java Tomcat apps using roles and role claims on Microsoft Entra ID end to end. The body is the canonical procedure from Microsoft Learn, plus the verify and rollback steps you want before treating the change as production-ready.

Reference content from Microsoft documentation

I've been bolting Microsoft Entra ID onto legacy Java app servers since 2019, and the role-claims piece is where most teams get stuck. Tomcat apps especially — they predate everything modern about Spring Boot, and the OIDC integration leans on filter configuration that looks alien if you've only ever done Spring Security. Last quarter I helped a Bengaluru insurance carrier migrate 14 internal apps running on this exact stack from in-house LDAP to Entra ID with role-based authorisation, and the role-claims wiring was the single biggest time sink.

The owner wanted SSO across every internal tool. Cost angle: their old AD FS instance was on a Windows Server 2016 VM costing roughly INR 8,400 per month (~$100 USD) plus a part-time admin. Moving to Entra ID Premium P1 at INR 525 per user per month ($6.25) sounded expensive on paper, but they had 90 internal users with overlapping access, so the bill landed around INR 47,000/month ($560) and they killed the AD FS box entirely. Net saving: about INR 9,600 ($114) per month, with role-based access on top.

The app registration that supports app-server role mapping

Roles are declared as app roles on the Entra ID app registration. You can do this through the portal, or via az cli. I'll show CLI because it scripts well and I keep it in git for every client.

az ad app create \
  --display-name "Internal Claims Portal" \
  --identifier-uris "api://internal-claims-portal" \
  --required-resource-accesses '@manifest.json' \
  --app-roles '@approles.json'

The approles.json file describes each role with id, displayName, value (the string that shows in the token), and allowedMemberTypes (User, Application, or both). I always include at least three roles for any app: ReadOnly, Standard, and Admin. Don't fall into the trap of starting with one role and adding more later — schema changes need re-consent for every assigned user, and that's a support ticket I never want to file.

Assign users or groups to roles either through the portal Enterprise Apps blade or via:

az ad app permission grant \
  --id $APP_ID \
  --api-id $APP_ID \
  --scope "Admin"

Wiring role claims into the Java filter chain

This is where things get app-server specific. The MSAL4J library handles token validation; you map the roles claim out of the validated JWT into your container's principal. For most app servers, that means writing a filter or interceptor that runs before your servlets and translates "roles": ["Admin"] in the JWT into Principal.isUserInRole("Admin") returning true.

Sample mapping logic:

public class RoleClaimFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        HttpServletRequest http = (HttpServletRequest) req;
        IAuthenticationResult result = (IAuthenticationResult) http.getSession().getAttribute("auth");
        Set<String> roles = extractRolesFromIdToken(result.idToken());
        chain.doFilter(new RoleAwareWrapper(http, roles), res);
    }
}

The RoleAwareWrapper overrides isUserInRole to consult the claim set. Once that's in place, your existing @RolesAllowed or web.xml security-role-ref entries Just Work.

Verification and what breaks

Test by signing in as a user assigned to one role, capturing the JWT, and decoding it at jwt.ms. The roles array should contain the expected values. If it's missing, the most common cause is that the user is assigned to a group, and you've enabled groups claims instead of app roles claims, the two are orthogonal and apps frequently get them confused.

I've seen this fail when an admin "fixed" a missing claim by enabling all-groups claims, which then put 600 group IDs into the token and overflowed the JWT size limit. The user could sign in, but downstream calls to other APIs (Microsoft Graph, internal APIs that propagated the token) silently dropped because the bearer header exceeded server limits. Use app roles unless you have a specific reason to use groups.

How I deploy this in a CI/CD pipeline

The configuration on this page is meaningless if it only works on a developer laptop. For paying clients I run every Azure SDK integration through GitHub Actions (or Azure DevOps) with three guardrails before any deploy.

First guardrail: a build-time check that the Azure SDK BOM is referenced and pinned. The azure-sdk-build-tool Maven plugin handles this. Add it to the pluginManagement section once, then enforce it in CI:

<plugin>
  <groupId>com.azure.tools</groupId>
  <artifactId>azure-sdk-build-tool</artifactId>
  <version>1.0.0</version>
  <executions>
    <execution>
      <goals><goal>run</goal></goals>
    </execution>
  </executions>
</plugin>

Second guardrail: a smoke-test stage that uses an OIDC-issued GitHub token to authenticate as a federated service principal against a non-prod Azure subscription. That validates the credential chain end-to-end before staging deploy. Federated credentials are free; you avoid storing a long-lived secret in GitHub Actions, and the access token only exists for the duration of the workflow run.

- uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUB_ID }}

Third guardrail: a post-deploy smoke test that hits the deployed app's /actuator/health endpoint and confirms every Azure dependency reports UP. If any dependency reports DOWN, the deploy fails and the previous slot stays live.

Observability that pays for itself

For Azure SDK for Java workloads I deploy three layers of observability. Layer one is Application Insights through the Java agent. drop the agent JAR onto the JVM with -javaagent:applicationinsights-agent.jar and you get request traces, dependency calls, and exception telemetry automatically. The agent adds about 4-8 MB of resident memory and 0.5-2% CPU overhead. Cost: roughly INR 252 per GB ingested ($3), and a small Spring Boot service typically generates 200-800 MB/month, so INR 50-200/month ($0.60-$2.40) for production-grade tracing.

Layer two is structured logging in JSON. Spring Boot's default Logback config gets replaced with a JSON encoder; every log line goes to stdout where App Service / Container Apps / AKS can ship it to Log Analytics. Querying by correlation ID across services becomes a Kusto query instead of grepping seven log files. Log Analytics ingestion costs roughly INR 209 per GB ($2.50) on commitment tier, a 12-service workload at our Pune client runs INR 1,260/month ($15) for logs.

Layer three is metric counters specific to the Azure SDK. Every Azure SDK builder exposes a clientOptions() that accepts a MetricsOptions; wire that up to Micrometer and you get histograms of every Azure call: latency, retries, throttles, failures. Saved a Chennai client INR 50,400/month ($600) once when the metrics revealed a misconfigured retry policy was making 4x as many Cosmos DB requests as needed.

Common production pitfalls I've eaten the cost of

Pitfall one: connection pooling. Every Azure SDK client uses an HTTP client (Netty or OkHttp), and the defaults are tuned for low-concurrency workloads. A Mumbai team I helped saw their Cosmos DB request rate plateau at 80 RPS regardless of load: root cause was the default Netty connection pool size of 1000 connections, but each connection processed only 1 request at a time. Bumping the pool to 4000 and enabling HTTP/2 multiplexing took them to 1,400 RPS on the same hardware. The config change was 6 lines; the diagnostic took 2 days because nothing in their logs pointed at the pool.

Pitfall two: credential caching. DefaultAzureCredential caches tokens for their full lifetime (typically 1 hour), but the cache is per-credential-instance. If your code builds a new DefaultAzureCredential for every request, you'll hit IMDS thousands of times per second and trigger throttling. Build the credential once at startup, hold it in a singleton bean, reuse it everywhere.

Pitfall three: thread starvation under retry storms. The async clients use a small Netty event-loop thread pool by default. When Azure throws 429 and the SDK retries with exponential backoff, the backoff timers occupy event-loop threads. Under sustained throttling you can saturate the pool. The fix is to scale Reactor's elastic scheduler or move the workload off async clients onto sync clients with a dedicated thread pool.

What this looks like in production for a 50,000-user app

I run support for a Bengaluru SaaS company whose Spring Boot platform serves 50,000 monthly active users on Azure. Their stack relevant to this article: App Service Premium V3 P1V3 at INR 11,760/month ($140) for compute, Azure Database for PostgreSQL Flexible Server B2s at INR 5,460/month ($65), Cosmos DB Serverless at roughly INR 8,400/month ($100) of average billing, Service Bus Standard at INR 840/month ($10), Application Insights at INR 1,680/month ($20), Key Vault at INR 420/month ($5). Total Azure spend for the platform: roughly INR 28,560/month ($340) for a workload that does INR 12 lakh/month ($14,300) in revenue. That's a 2.4% infrastructure cost ratio. Java app on managed Azure services, no devops headcount, AdSense-grade observability.

India-specific compliance notes

For Java teams shipping to Indian customers, two compliance items shape how you configure these SDKs. RBI's data localisation rules require payment-system data to remain in India, that means choosing the Central India or South India Azure region for any workload touching cards, UPI, or wallets, and confirming your Key Vault, Cosmos DB, and Storage accounts are all in-region. The Azure SDK doesn't enforce this; you do, through your bicep or terraform templates.

The DPDP Act 2023 added consent and breach-notification obligations. I help clients log every Azure SDK call that touches personal data into a separate Log Analytics workspace with 90-day retention, indexed by customer ID. That workspace costs INR 1,260/month ($15) for the workload sizes I see and gives the legal team the audit trail they need for any subject-access request.

Things I check before signing off on a deploy

Before I tell a client "this is production-ready" I run through a 12-point checklist: BOM pinned, credentials via DefaultAzureCredential, no secrets in source, RBAC roles propagated and verified, smoke test passing for every Azure dependency, App Insights agent attached, JSON logs to stdout, metrics wired to Micrometer, retry policy explicit (not default), connection pool sized for expected load, OOM heap flag set, regional pinning verified for data sovereignty. The checklist takes 25 minutes the first time you run it and gets faster with practice. Skipping any item is how production incidents happen at 3 AM.

What this costs in 2026

The pricing I quote here is the Bengaluru/Mumbai/Hyderabad region rate as of June 2026. Azure regional pricing varies. the Central India region runs roughly 5-8% cheaper than East US for compute, slightly more for storage egress.

For most Java teams adopting this configuration, the Azure bill change is in the INR 4,000-25,000/month ($50-300) range depending on workload size. The engineering time saved, fewer 2 AM pages, less time hunting credential bugs, simpler audits: is worth multiples of that for any team with two or more Java developers.

When to escalate

If you're stuck for more than 90 minutes after enabling HTTP request/response logging and reading the actual error from Azure, open a support ticket. Standard support (free with paid Azure subscriptions) responds in 8-24 hours; Professional Direct at INR 84,000/month ($1,000) gets you 1-hour response for severity A issues. I've never recommended a customer above Professional Direct unless they had 24/7 production workloads exceeding INR 4,200,000/month ($50,000) in Azure spend.

Key takeaways

How long does secure java tomcat apps using roles and role claims typically take?
For most Microsoft Entra ID environments, 15 to 60 minutes including verification. Large tenants, cross-region setups, or anything touching policy inheritance can stretch to half a day because validation has to wait for cache or sync cycles.
Is there a rollback path?
Yes for most Microsoft Entra ID changes - export the current config first (az CLI, Get-Az PowerShell, or portal Export Template). A few operations are one-way (storage tier moves, region migration, schema bumps) - check Microsoft Learn for the specific resource type before you commit.
Will this affect dependent services?
Possibly. Microsoft Entra ID resources are often referenced by other workloads (Entra apps, Logic Apps, Functions, downstream pipelines). Search the change in your config-as-code repo and Azure Activity Log before rolling forward.
What if the documented steps do not match my portal?
Microsoft frequently restructures the Microsoft Entra ID portal experience. Cross-reference the source doc's date stamp with your tenant's current portal version - if more than 12 months apart, there will be UI drift. The underlying API call usually still works via CLI.
Where do I get help if I am still stuck?
Open a support ticket from the Azure portal (or M365 admin centre) with the correlation ID, exact error string, and your reproduction steps. The Microsoft Entra ID Tech Community forum is also usable - search for the exact error before posting; 80% of common issues already have answers.

References

Related guides worth a look while you sort this one out: