Week 19: Advanced Security — OAuth2 Authorization Server & Multi-Tenancy

Weeks 6–7 had your service consume OAuth2/OIDC — validating a JWT someone else issued. This week flips that: running your own Spring Authorization Server, so your platform is the one issuing tokens to first-party apps and third-party API clients alike. It also covers the two access patterns Weeks 6–7 didn't — machine-to-machine authentication with API keys and client credentials, and isolating data correctly once a single deployment serves more than one tenant.

Module 16 of 22 Week 19 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Stand up a Spring Authorization Server issuing real tokens to registered clients
  • Authenticate service-to-service calls with API keys and the client credentials grant
  • Choose and implement a multi-tenant data isolation strategy

1. Running Your Own Authorization Server

Week 6 configured your app as an OAuth2 resource server and, for login, an OAuth2 client of an external provider — Google, GitHub, an identity provider your company already runs. Some platforms need to go one level deeper: issuing their own tokens to their own first-party mobile app, a partner's third-party integration, and internal services alike. Spring Authorization Server is Spring's own implementation of that third role — the authorization server itself.

pom.xml
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
registering a client application
@Bean
RegisteredClientRepository registeredClientRepository() {
    RegisteredClient webApp = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("acme-web-app")
        .clientSecret("{noop}web-app-secret")     // {noop} for dev only -- see callout
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.acme.com/login/oauth2/code/acme")
        .scope(OidcScopes.OPENID)
        .scope("tasks:read")
        .scope("tasks:write")
        .build();

    return new InMemoryRegisteredClientRepository(webApp);
}

This is the same authorization code grant from Week 6 — the one a browser-based login flow uses — except your server is now the one issuing the code and, ultimately, the access token, rather than delegating to Google or GitHub. Custom, application-specific scopes like tasks:read and tasks:write become possible in a way an external provider's fixed scope set never allowed, letting you express your own API's actual permission boundaries directly in the tokens you issue.

Building an authorization server is a serious commitment — build vs. buy is a real question here

Correctly implementing token revocation, refresh rotation, PKCE, and every OAuth2 edge case is meaningfully harder to get right than consuming someone else's. Auth0, Okta, or Keycloak solve this as a managed or self-hosted product with years of security hardening behind them; reach for Spring Authorization Server specifically when you need behavior those platforms genuinely can't give you (fully custom grant types, tight integration with an existing Spring domain model), not as a default over a proven identity provider.

2. API Keys & the Client Credentials Grant

Every grant type in Week 6 and Section 1 assumes a human is present to log in. A scheduled batch job, a webhook receiver, or a server calling your API with no user in the loop needs a different pattern entirely — there's no browser to redirect and no password to prompt for.

The client credentials grant is OAuth2's answer: the calling service authenticates directly with its own client ID and secret, no user or redirect involved, and receives an access token scoped to what that service is allowed to do.

registering a machine client & requesting a token
RegisteredClient reportingService = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("reporting-service")
    .clientSecret("{noop}reporting-secret")
    .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
    .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
    .scope("reports:generate")
    .build();
terminal — a service fetching its own token, no user involved
curl -X POST https://auth.acme.com/oauth2/token \
  -u reporting-service:reporting-secret \
  -d "grant_type=client_credentials&scope=reports:generate"

# {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600}

For simpler internal or partner integrations where full OAuth2 is more machinery than the situation needs, a plain API key — a long random string, checked against a hashed value stored server-side, tied to an owning account and a set of permissions — is a legitimate, lighter-weight alternative:

a simple API key authentication filter
@Component
class ApiKeyAuthFilter extends OncePerRequestFilter {

    private final ApiKeyRepository apiKeyRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                     FilterChain chain) throws IOException, ServletException {
        String rawKey = req.getHeader("X-API-Key");
        if (rawKey != null) {
            apiKeyRepository.findByHashedKey(hash(rawKey))
                .filter(ApiKey::isActive)
                .ifPresent(key -> SecurityContextHolder.getContext()
                    .setAuthentication(new ApiKeyAuthentication(key.owner(), key.scopes())));
        }
        chain.doFilter(req, res);
    }
}

The key's hash is what's stored and compared, exactly like Week 6's password hashing — a leaked database dump shouldn't hand out usable API keys any more than it should hand out usable passwords.

Client credentials vs. API keys is a real design choice, not just a preference

Client credentials gives you standard, short-lived, scoped tokens with a real revocation and rotation story built into the OAuth2 protocol — better for internal service-to-service calls where you control both ends. A plain API key is simpler to issue and integrate for external partners who don't want to implement a full OAuth2 client, at the cost of typically being longer-lived and needing your own revocation and rotation tooling built by hand.

3. Multi-Tenant Data Isolation

Once a single deployment serves multiple customers ("tenants") from shared infrastructure, the single most important property the application has to guarantee is that Tenant A's request can never return Tenant B's data — not "usually doesn't," but structurally cannot. Three common strategies, in increasing order of isolation and operational cost:

  • Shared schema, discriminator column — every table gets a tenant_id column, and every query filters by it. Cheapest to run and simplest to add a tenant to, but the isolation is only as strong as every single query remembering to filter correctly.
  • Schema-per-tenant — one Postgres schema per tenant, same database instance. Stronger isolation (a missing filter can't cross schemas as easily), but migrations and connection routing get meaningfully more complex as tenant count grows.
  • Database-per-tenant — full physical isolation. Strongest guarantee, but the most operationally expensive: connection pooling, migrations, and backups now all multiply by tenant count.

For the common shared-schema approach, Hibernate's filter mechanism enforces the tenant boundary at the ORM layer, rather than trusting every hand-written query to remember the WHERE tenant_id = ? clause:

enforcing tenant isolation with a Hibernate filter
@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = Long.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
class Task {
    @Id Long id;
    Long tenantId;
    String title;
    // ...
}

// enabled once per request, from the authenticated tenant's context
@Component
class TenantFilterInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        Long tenantId = TenantContext.currentTenantId();   // from the authenticated principal
        entityManager.unwrap(Session.class)
            .enableFilter("tenantFilter")
            .setParameter("tenantId", tenantId);
        return true;
    }
}

With the filter enabled, every query issued through that EntityManager for the rest of the request automatically has the tenant condition applied — a developer writing a new repository method later can't accidentally forget the filter, because it isn't something they write per query at all.

A cross-tenant data leak is the worst-case failure mode in a multi-tenant system

Unlike most bugs, a query that returns another tenant's data is a genuine security incident and breach of contract, not just a defect — treat any code path that queries tenant-scoped data without going through the enforced filter as a blocking issue in code review, and add an integration test that specifically asserts Tenant A's API calls, using Tenant A's credentials, can never return a row belonging to Tenant B.

4. Hands-on Exercise

Hands-on

Stand up your own auth server, add a machine client, and enforce tenant isolation

Apply all three practices to the task service from earlier weeks.

Requirements:

  1. Stand up a Spring Authorization Server registering one browser-based client (authorization code grant) and confirm a full login flow issues a real, working access token.
  2. Register a second, machine-only client using the client credentials grant, and confirm it can fetch a token and call a protected endpoint with no user or browser involved.
  3. Add a tenant_id column to your task table, seed data for at least two tenants, and implement the Hibernate filter pattern from Section 3 to enforce isolation.
  4. Write an integration test that authenticates as Tenant A and asserts every list/search endpoint returns zero rows belonging to Tenant B, even when Tenant B's IDs are guessed directly in a path parameter.
Hint

Test the isolation boundary by directly requesting a resource ID you know belongs to the other tenant — GET /api/tasks/{known-tenant-b-id} while authenticated as Tenant A should return 404, not the data, and definitely not a 403 that confirms the resource exists.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

What's the difference between the role your service played as an OAuth2 resource server in Week 6 and the role Spring Authorization Server plays here?

As a resource server, your service only validated tokens someone else (an external identity provider) issued — it never created them. Running Spring Authorization Server means your service is the one issuing tokens, registering client applications, and defining the scopes those tokens carry, taking on the role an external provider like Google or Okta previously played.

Q2

Why does the client credentials grant not involve a redirect or a login form, unlike the authorization code grant?

The client credentials grant exists specifically for machine-to-machine calls where no human is present to authenticate — the calling service proves its own identity directly with a client ID and secret, in one request, rather than needing a browser to redirect a user through a login screen and back.

Q3

Why is enforcing tenant isolation through a Hibernate filter more robust than trusting every repository method to add its own WHERE tenant_id = ? clause?

A filter applied once per request, at the session level, automatically constrains every query issued through that EntityManager for the rest of the request — a new repository method written later inherits the protection without a developer needing to remember to add it. Relying on every individual query to manually include the tenant condition means a single missed filter, anywhere in the codebase, is a cross-tenant data leak.