Week 7: Spring Security (Part 2) — OAuth2 & Authorization

Week 6 built username/password JWT login from scratch. This week adds a second, more familiar front door — "Sign in with Google"-style OAuth2/OIDC login — as an alternative to that flow, then turns to a different question entirely: now that you know who a request is from, what should they actually be allowed to do? You'll lock down operations with role-based, method-level authorization, and close out with CORS and CSRF, two topics that only make sense once a real browser-based frontend enters the picture.

Module 5 of 12 Week 7 of 15 ~4–5 Hours Hands-on Exercise Included

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

  • Understand OAuth2/OIDC concepts and wire up OAuth2 login with Spring Security
  • Enforce role-based access with @PreAuthorize method-level authorization
  • Configure CORS and CSRF correctly for a stateless API called from a browser

1. OAuth2 & OIDC Concepts

Before writing any Spring code, it's worth being precise about what OAuth2 actually solves, because it's commonly misused as a synonym for "login." OAuth2 is, at its core, an authorization framework — it lets an application obtain limited access to a resource on a user's behalf, without ever seeing that user's password. "Sign in with Google" is one application of that framework, not the whole of it.

Three roles

Every OAuth2 flow involves three actors talking to each other. The authorization server (Google, GitHub, Okta, your own identity provider) authenticates the user and issues tokens. The resource server hosts the protected data the client wants — often the same server as the authorization server for a big provider like Google, but a separate system entirely in an enterprise setup. The client is the application requesting access on the user's behalf — in this course, your Spring Boot Task API.

The authorization code flow, conceptually

The flow your browser goes through when you click "Sign in with Google" follows a consistent shape, regardless of provider:

authorization code flow -- conceptual
1. Browser hits your app's "Login with Google" link
   -> redirected to accounts.google.com with your app's client-id

2. User logs in at Google (your app never sees their Google password)
   and approves the permissions your app is requesting

3. Google redirects back to your app with a one-time authorization code
   GET /login/oauth2/code/google?code=4/0AY0e-g7...

4. Your app's backend exchanges that code for tokens -- server-to-server,
   never visible to the browser -- by calling Google's token endpoint
   with the code, client-id, and client-secret

5. Google returns an access token (and, for OIDC, an ID token)
   -> your app now knows who the user is and can call Google APIs
      on their behalf, within the granted scope

The authorization code that bounces through the browser in step 3 is deliberately short-lived and useless on its own — it can only be redeemed for real tokens by a client that also knows the client secret, in a direct server-to-server call the browser never sees. That's what keeps the actual access token out of the browser's history and any JavaScript that might be running on the page.

What OIDC adds: identity on top of authorization

Plain OAuth2 answers "can this app access this resource?" — it was never designed to answer "who is this person?" OpenID Connect (OIDC) is a thin identity layer built on top of OAuth2 that closes exactly that gap: alongside the access token, the authorization server also issues an ID token, a signed JWT containing standardized claims about the user — sub (a stable unique identifier), email, name, picture, and so on. That ID token is what actually lets your app say "this session belongs to ada@example.com" with cryptographic confidence, rather than inferring identity indirectly from what an access token happens to unlock.

This distinction is why nearly every "social login" integration you've used is OIDC, not bare OAuth2 — and why teams reach for it instead of rolling their own social login: the provider has already solved multi-factor auth, account recovery, breach detection, and consent screens, and hands you back a standardized, signed identity token instead of a pile of provider-specific quirks to reverse-engineer.

This isn't replacing Week 6

OAuth2/OIDC login is an alternative front door, not a rewrite of what you built last week. The JWT-based username/password flow from Week 6 keeps working exactly as before -- you're adding a second way for a user to arrive at an authenticated session, not removing the first.

2. OAuth2 Login with Spring Security

Spring Security implements the entire authorization code flow from section 1 for you — redirects, state/nonce validation, the server-to-server token exchange, and ID token verification — behind one starter and a handful of configuration properties.

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Registering a provider

Google (and most major providers) are pre-configured in Spring Boot's OAuth2 client support -- you only need to supply the credentials you get from Google's developer console, plus the scopes you're requesting:

application.properties
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=openid,profile,email

Just like the JWT secret in Week 6, these credentials are loaded from environment variables rather than hard-coded -- a client secret checked into source control is a standing invitation for anyone with repo access to impersonate your app to Google.

The endpoints Spring Boot wires up for free

Registering a provider auto-configures two URLs, and you never write a controller for either of them -- Spring Security's oauth2Login() DSL handles both:

auto-configured endpoints
GET /oauth2/authorization/google
    -> starts the flow: redirects the browser to Google's consent screen

GET /login/oauth2/code/google
    -> the redirect URI Google sends the browser back to with the
       authorization code; Spring Security exchanges it for tokens here
SecurityConfig.java -- enabling OAuth2 login
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sm ->
                    sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/auth/login", "/auth/register", "/oauth2/**", "/login/**").permitAll()
                    .anyRequest().authenticated())
            .oauth2Login(oauth2 -> oauth2
                    .successHandler(oAuth2LoginSuccessHandler)) // section below
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}

Bridging OAuth2User into your own User table

After a successful OAuth2 login, Spring Security hands you an OAuth2User (or, for OIDC providers like Google, an OidcUser) populated with the provider's claims -- but that object isn't your User entity from Week 6, and it isn't persisted anywhere. A custom success handler is the bridge: it looks up or creates a row in the same app_user table, then issues the exact same kind of JWT your /auth/login endpoint issues, so everything downstream of login -- the JWT filter, the Task endpoints, method-level authorization -- treats both login paths identically.

OAuth2LoginSuccessHandler.java
package com.codeverse.week07.security;

import com.codeverse.week07.user.Role;
import com.codeverse.week07.user.User;
import com.codeverse.week07.user.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final UserRepository userRepository;
    private final JwtService jwtService;

    public OAuth2LoginSuccessHandler(UserRepository userRepository, JwtService jwtService) {
        this.userRepository = userRepository;
        this.jwtService = jwtService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException {

        OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
        String email = oidcUser.getEmail();

        User user = userRepository.findByUsername(email)
                .orElseGet(() -> {
                    User created = new User();
                    created.setUsername(email);
                    created.setPasswordHash(null); // no local password -- OAuth2-only account
                    created.setRole(Role.USER);
                    return userRepository.save(created);
                });

        String token = jwtService.generateToken(user.getUsername());

        // Hand the token back the same shape /auth/login uses, so the frontend
        // doesn't need two different response formats to deal with.
        response.setContentType("application/json");
        response.getWriter().write("{\"token\": \"" + token + "\"}");
    }
}

Note the orElseGet — the first time a given Google account logs in, it silently provisions a matching User row with a null password hash (there's nothing to hash; this account never sets a local password) and the default Role.USER. Every login after that finds the existing row by email instead of creating duplicates.

3. Method-Level Authorization

Week 6's authorizeHttpRequests answers one question well: "is this URL reachable at all without a valid token?" It can't express anything more specific than that from the security config alone — "only the task's owner can delete it" or "only admins can delete arbitrary tasks" needs information that only exists once you're inside the method, looking at the request and the resource together. That's what method-level authorization is for.

Enabling it

One annotation on a configuration class turns on @PreAuthorize/@PostAuthorize support across the app:

MethodSecurityConfig.java
package com.codeverse.week07.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
    // No beans needed -- the annotation alone activates method-level checks
    // wherever @PreAuthorize / @PostAuthorize / @Secured are used.
}

Roles vs. granted authorities

A granted authority is just a string Spring Security attaches to an authenticated principal — "ROLE_ADMIN", but also things like "tasks:write" for finer-grained permissions. A role is a specific convention layered on top: any authority prefixed with ROLE_ is treated as a role, and expressions like hasRole("ADMIN") automatically add that prefix for you -- hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") check the exact same thing. This week sticks to roles, since USER/ADMIN is enough for the Task API.

@PreAuthorize with SpEL

@PreAuthorize evaluates a Spring Expression Language (SpEL) condition before the method body runs, and can reference the method's own arguments — which is exactly what lets you express "the caller is either an admin, or the owner of this specific task":

TaskService.java -- excerpt
package com.codeverse.week07.task;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    // Any authenticated user may delete their own task; only an ADMIN may
    // delete a task belonging to someone else. isOwner() is a small helper
    // bean exposed to SpEL below.
    @PreAuthorize("hasRole('ADMIN') or @taskSecurity.isOwner(#taskId, authentication.name)")
    public void deleteTask(Long taskId) {
        taskRepository.deleteById(taskId);
    }

    // Admin-only: hard delete regardless of ownership, used from an admin panel.
    @PreAuthorize("hasRole('ADMIN')")
    public void adminDeleteTask(Long taskId) {
        taskRepository.deleteById(taskId);
    }
}
TaskSecurity.java -- the @taskSecurity SpEL bean
package com.codeverse.week07.task;

import org.springframework.stereotype.Component;

@Component("taskSecurity")
public class TaskSecurity {

    private final TaskRepository taskRepository;

    public TaskSecurity(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public boolean isOwner(Long taskId, String username) {
        return taskRepository.findById(taskId)
                .map(task -> task.getOwnerUsername().equals(username))
                .orElse(false);
    }
}

If the SpEL expression evaluates to false, Spring Security throws an AccessDeniedException before deleteTask's body ever executes, and the response comes back as a 403 Forbidden -- the request was authenticated (a 401 would mean it wasn't), it's just not permitted. That's the exact distinction Week 6 flagged as "coming in Week 7."

URL-pattern rules vs. method-level rules

These two mechanisms aren't competitors -- they operate at different layers and are meant to be used together. authorizeHttpRequests is coarse and fast: it runs in the filter chain, before any controller code executes, and is the right tool for "this whole path requires some authenticated user" or "this whole admin section requires the ADMIN role," expressed once in the security config. @PreAuthorize is fine-grained and contextual: it runs at the service or controller method, has access to the actual method arguments, and is the only place a rule like "only this task's owner" can live, because ownership isn't knowable from the URL alone. A well-designed app layers both: broad gates at the filter chain, precise checks at the method that actually touches the resource.

Looking ahead to Week 8

Once controllers depend on who's calling and what role they hold, testing has to account for that too -- Week 8 covers how to write @WebMvcTest and @SpringBootTest cases that run the same endpoint as a plain USER, as an ADMIN, and as nobody at all, and assert the right status code comes back for each.

4. CORS & CSRF in a Stateless API

These two acronyms get confused constantly, partly because both are browser security mechanisms and both showed up already in this week's and last week's SecurityFilterChain config -- but they defend against different threats, and a stateless, token-based API treats them very differently.

Why CSRF is disabled here

CSRF (Cross-Site Request Forgery) exploits the fact that browsers automatically attach cookies to every request to a domain, including requests triggered by a malicious third-party page the user happens to have open. If your API authenticated via a session cookie, a hostile page could silently trigger POST /api/tasks/1/delete and the browser would obligingly attach your session cookie, with no way for the server to tell that request apart from one you made yourself -- that's exactly what CSRF tokens exist to prevent.

This Task API doesn't have that problem, by construction. Every request -- JWT-authenticated or OAuth2-derived -- carries its credential in an Authorization: Bearer <token> header, which the browser never attaches automatically the way it does cookies; a malicious page has no way to make the victim's browser send that header on its behalf. No cookies for authentication means no ambient credential for an attacker to ride on, which is precisely why csrf(csrf -> csrf.disable()) was correct back in Week 6 and stays correct this week: there's no CSRF risk to defend against in a stateless, header-authenticated API.

Why CORS still matters

CORS (Cross-Origin Resource Sharing) is a completely different concern: it's the browser's own default-deny policy for cross-origin fetch/XHR calls made from JavaScript, regardless of how those calls are authenticated. If your frontend is served from http://localhost:4200 (an Angular or React dev server) and your API runs on http://localhost:8080, those are different origins by the browser's definition -- and without an explicit CORS policy telling the browser "requests from that origin are allowed," the browser blocks the frontend's JavaScript from reading the response, even if the API itself would have happily served it.

CorsConfig.java
package com.codeverse.week07.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
public class CorsConfig {

    @Value("${app.cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins);              // explicit list, never "*"
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
application.properties
app.cors.allowed-origins=http://localhost:4200
Looking ahead to Week 12

Once requests carry a real identity -- whether from Week 6's JWT login or this week's OAuth2 flow -- that identity becomes something worth recording, not just checking. Week 12 covers auditing: capturing who created, updated, or deleted a given row, using the same authenticated principal this week's @PreAuthorize checks already rely on.

Wiring the bean into the filter chain is one line -- Spring Security picks up a CorsConfigurationSource bean automatically once you reference it:

SecurityConfig.java -- excerpt
return http
        .cors(cors -> cors.configurationSource(corsConfigurationSource))
        .csrf(csrf -> csrf.disable())
        // ...rest unchanged from section 2
        .build();

Never wildcard everything in production

setAllowedOrigins(List.of("*")) looks tempting during local development because it "just works" against any frontend, but it's a production liability the moment credentials are involved -- covered in the quiz below. Keep the allowed-origin list explicit and environment-specific: localhost:4200 locally, your real frontend's domain in production, loaded from configuration exactly like the JWT secret and OAuth2 client credentials.

5. Hands-on Exercise

Hands-on

Add roles, method-level authorization, and CORS to the Task API

Extend the JWT-secured Task API from Week 6 with real authorization: roles, ownership checks, a browser-facing CORS policy, and -- optionally -- OAuth2 login as a second way in.

Requirements:

  1. Add a Role enum (USER, ADMIN) and a role field on the Week 6 User entity, defaulting new registrations to USER. Seed at least one ADMIN user directly in the database or via a startup CommandLineRunner.
  2. Add @EnableMethodSecurity and implement a deleteTask(Long taskId) that regular users can only call successfully against tasks they own, while an ADMIN can delete any task -- using @PreAuthorize with a SpEL expression referencing an ownership-check helper bean, following the TaskSecurity pattern above.
  3. Add a separate admin-only endpoint (e.g. DELETE /api/admin/tasks/{id}) guarded with @PreAuthorize("hasRole('ADMIN')"), and confirm with curl that a USER token gets a 403 while an ADMIN token succeeds.
  4. Configure a CorsConfigurationSource bean that allows your local frontend's origin (e.g. http://localhost:4200) with an explicit origin list -- not a wildcard -- and wire it into the SecurityFilterChain with .cors(...).
  5. (Optional, extra credit) Add spring-boot-starter-oauth2-client, register a Google OAuth2 client, and implement an AuthenticationSuccessHandler that finds-or-creates a User row by email and issues the same JWT format as /auth/login, so a frontend can treat both login paths identically.
Hint

Your Week 6 JwtAuthenticationFilter and UserDetailsService don't need to change for roles to work -- just make sure loadUserByUsername maps the entity's role field onto .authorities("ROLE_" + user.getRole()) instead of the hard-coded "ROLE_USER" string from last week.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does OIDC add on top of plain OAuth2, and why does it matter for "Sign in with Google"-style login?

Plain OAuth2 is an authorization framework -- it's built to answer "can this app access this resource on the user's behalf?" and issues an access token scoped to that resource, without any standardized notion of who the user actually is. OIDC layers identity on top: alongside the access token, the authorization server also issues a signed ID token, a JWT carrying standardized claims like sub, email, and name. That ID token is what actually answers "who is this person?" with cryptographic confidence, which is the piece "Sign in with Google" fundamentally needs -- without OIDC, an app would only know it was granted some scope of access, not whose account it belongs to.

Q2

What's the practical difference between a @PreAuthorize role check and an authorizeHttpRequests URL pattern rule?

authorizeHttpRequests rules live in the security filter chain and run before any controller code executes -- they can only reason about the request's path and method, which makes them ideal for coarse, blanket rules like "everything under /api/admin/** requires the ADMIN role." @PreAuthorize runs at the method itself, with full access to the method's actual arguments (and, via a SpEL helper bean, to data fetched from the database), so it can express rules URL patterns fundamentally can't -- like "this specific task can only be deleted by its owner or an admin," where the answer depends on the resource, not just the path. In practice they're complementary: broad gates at the filter chain, precise ownership checks at the method that touches the resource.

Q3

Why is CSRF protection typically disabled for a token-based API, while CORS still needs to be configured?

CSRF attacks work by exploiting the browser's habit of automatically attaching cookies to every request to a given domain, including requests a malicious third-party page silently triggers -- so a session-cookie-authenticated app is vulnerable unless it defends against forged requests riding on that ambient cookie. This Task API never authenticates via cookies; every request carries its credential explicitly in an Authorization: Bearer header, which the browser does not attach automatically the way it does cookies, so a malicious page has no way to forge that header on a victim's behalf -- there's no ambient credential to ride on, hence no CSRF risk to defend against. CORS is unrelated: it's the browser's own default-deny policy for any cross-origin JavaScript call, regardless of how it's authenticated, so a legitimate frontend served from a different origin than the API still needs an explicit CORS policy or the browser blocks its requests outright.

Q4

Why is a wildcard CORS origin ("*") dangerous once endpoints require credentials?

An allowed origin of "*" tells the browser that a script running on literally any website is permitted to read responses from your API -- fine for a truly public, unauthenticated endpoint, but catastrophic once requests carry a bearer token or credentials, because any malicious site the victim happens to have open could make an authenticated call to your API from the victim's browser and read the response back into its own JavaScript. Browsers actually enforce this: the CORS spec forbids combining a wildcard origin with allowCredentials(true), and a compliant browser will reject that combination outright. The safe pattern is an explicit, environment-specific allow-list -- localhost:4200 in development, your real frontend's domain in production -- so only origins you actually trust can read authenticated responses.