Skip to main content

Class 6: Security & Authentication

Duration: 65 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 5 completed


What You'll Learn

By the end of this class, you will:

  • Understand why GraphQL authentication works differently from REST
  • Implement JWT-based stateless authentication
  • Create user registration and login mutations
  • Protect mutations with role-based access control using @PreAuthorize
  • Understand how Spring Security integrates with Spring GraphQL

Why GraphQL Security Is Different

In Class 5 (Github repo here), we made error handling deliberate and consistent with custom exceptions, error classifications, and typed responses. But every query and mutation we have built so far is wide open: anyone can read, create, update, or delete. This lesson adds the missing layer - authentication to establish who is calling, and authorization to decide what they are allowed to do.

In a REST API, you typically secure entire endpoints: POST /api/movies requires ADMIN, GET /api/movies is public. Each URL has a clear security rule.

GraphQL has one endpoint - /graphql. Every query, mutation, and subscription goes through it. You can't secure by URL because the same endpoint serves both public queries (movies) and admin mutations (deletePerson). Instead, security must happen at the field resolver level - each @QueryMapping or @MutationMapping method decides independently whether the current user is authorized.

This is actually more flexible than REST's endpoint-based security. You can have a single query that returns different data based on the user's role, or a mutation where some fields are public and others require authentication. But it means we need to set up the security infrastructure carefully.

Authentication in Production

In production, most teams handle authentication (login, registration, token refresh) through a dedicated auth service like Auth0, Keycloak, or AWS Cognito, or via separate REST endpoints. GraphQL then only deals with authorization - verifying the token and checking permissions per resolver. Auth flows often involve redirects (OAuth), cookie management, and other HTTP-specific concerns that don't map well to GraphQL's single-endpoint model.

We implement authentication as GraphQL mutations here to keep the tutorial self-contained. The important part is the authorization patterns (@PreAuthorize, JWT filter, SecurityContextHolder) - these work exactly the same regardless of how the token was originally issued.

The Authentication Flow

Here's how JWT authentication works in our GraphQL API:

The key insight is that authentication (proving who you are) happens in a servlet filter before GraphQL even sees the request. Authorization (checking what you're allowed to do) happens inside individual resolvers using Spring Security's @PreAuthorize.

Step 1: Add Dependencies

Add Spring Security and the JWT library to your pom.xml:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
Heads Up

The moment you add spring-boot-starter-security, Spring Boot auto-configures security that blocks everything by default. Your GraphiQL and all queries will stop working until you configure the security filter chain. Don't panic - that's expected, and we'll fix it in Step 6.

Step 2: Update the Schema

Before writing any Java code, let's define the API contract for authentication: the types, queries, and mutations our clients will use. This schema-first approach lets us think about the API from the consumer's perspective before getting into implementation details.

📁 src/main/resources/graphql/schema.graphqls, add auth types and mutations:

type Mutation {
# ... existing mutations
"""Authenticate with username and password, returns a JWT token"""
login(input: LoginInput!): AuthResponse!

# Admin only (enforced by @PreAuthorize on the service, not by schema)
"""Delete a movie by ID (requires ADMIN role)"""
deleteMovie(id: ID!): DeleteMovieResponse!
"""Create a new person (requires ADMIN role)"""
createPerson(input: CreatePersonInput!): Person!
"""Update an existing person (requires ADMIN role)"""
updatePerson(input: UpdatePersonInput!): Person
"""Delete a person by ID (requires ADMIN role)"""
deletePerson(id: ID!): DeletePersonResponse!
}

"""User roles for access control"""
enum Role {
USER
ADMIN
}

"""A registered user of the application"""
type User {
id: ID!
username: String!
email: String!
role: Role!
}

"""JWT token and user data returned after successful authentication"""
type AuthResponse {
"""JWT token to include in the Authorization header"""
token: String!
user: User!
}

"""Credentials for user login"""
input LoginInput {
username: String!
password: String!
}

Notice that this schema doesn't express authorization rules. Nothing in the SDL marks createPerson as "admin only". That rule lives in the Java code, enforced by @PreAuthorize. It follows the principle the official docs recommend: the schema defines shape, and the business layer enforces policy as the single source of truth. The GraphQL specification takes no position on authorization. The words authorization, authentication and permission never appear in it, and a 2017 proposal to add a built-in authorization directive was closed as out of scope. As one GraphQL Working Group member put it, the spec "does not care about authorization: you're free to implement that however you want."

You can express authorization in SDL - with a caveat

Saying there is "no way" to put authorization in the schema would be too strong. You can, with a custom directive - and directives are precisely the spec's sanctioned extension point. The official docs even show the pattern:

directive @auth(rule: Rule) on FIELD_DEFINITION
enum Rule { IS_AUTHOR }

Some platforms enforce such directives for real. Apollo Federation's @authenticated, @requiresScopes, and @policy are evaluated by the GraphOS Router, which strips unauthorized fields before the query is even planned. AWS AppSync's @aws_cognito_user_pools and @aws_auth(cognito_groups: ["Admins"]) restrict a field or an entire mutation to a group - the direct SDL equivalent of an admin-only createPerson.

The catch, and the reason we keep the rule in Java, is that a directive is inert until a runtime reads and enforces it. By itself it is only documentation. Declaring @auth without wiring the enforcement is worse than leaving it out, because the schema then advertises a protection that is missing. Spring for GraphQL offers method security (@PreAuthorize) rather than a built-in authorization directive, so @PreAuthorize is where enforcement genuinely happens. Even teams that do surface authorization in the schema are advised to keep the real policy in the business layer as the single source of truth.

We keep this surface deliberately small - login plus the write operations we want to protect - because the point of this class is the security machinery, not a full user-management API. Users are seeded in Step 12, so you can log in the moment the app starts; adding a self-service register mutation and a me query is left as an exercise at the end.

With the contract in place, let's build the implementation to back it.

Step 3: Create the User Entity

📁 src/main/java/com/graphqlguy/moviedb/user/Role.java

package com.graphqlguy.moviedb.user;

public enum Role {
USER, ADMIN
}

📁 src/main/java/com/graphqlguy/moviedb/user/AppUser.java

package com.graphqlguy.moviedb.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "app_users")
public class AppUser {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String username;

@Column(unique = true, nullable = false)
private String email;

@Column(nullable = false)
private String password;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
}

We name it AppUser rather than User to avoid conflicts with SQL reserved keywords and Spring Security's own User class. The password field will store a bcrypt hash - never the plain-text password.

📁 src/main/java/com/graphqlguy/moviedb/user/UserRepository.java

package com.graphqlguy.moviedb.user;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
}

login needs only findByUsername. The existsByUsername/existsByEmail derived queries belong to the register exercise, so we leave them out of the core build.

Step 4: Create the JWT Utility

JWT (JSON Web Token) is a compact, self-contained token format. When a user logs in, we create a JWT containing their username and role, sign it with a secret key, and send it back. On subsequent requests, the client sends this token in the Authorization header, and we verify it without needing to check the database.

📁 src/main/java/com/graphqlguy/moviedb/security/JwtUtil.java

package com.graphqlguy.moviedb.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HexFormat;

@Component
public class JwtUtil {

@Value("${jwt.secret}")
private String secret;

@Value("${jwt.expiration}")
private long expiration;

private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(HexFormat.of().parseHex(secret));
}

public String generateToken(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}

public String extractUsername(String token) {
return extractClaims(token).getSubject();
}

public String extractRole(String token) {
return extractClaims(token).get("role", String.class);
}

public boolean isTokenValid(String token) {
try {
extractClaims(token);
return true;
} catch (Exception e) {
return false;
}
}

private Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}

The secret key should be at least 256 bits for HMAC-SHA256. We store it in application.yaml as a hex string. In production, you'd use an environment variable or a secrets manager - never hardcode secrets in source code.

Step 5: Create the JWT Filter

The filter intercepts every HTTP request, checks for a Bearer token in the Authorization header, validates it, and sets up the Spring Security context so downstream code knows who the user is.

📁 src/main/java/com/graphqlguy/moviedb/security/JwtAuthFilter.java

package com.graphqlguy.moviedb.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");

if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.isTokenValid(token)) {
String username = jwtUtil.extractUsername(token);
String role = jwtUtil.extractRole(token);
var auth = new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}

filterChain.doFilter(request, response);
}
}

Notice the "ROLE_" prefix. Spring Security's hasRole('ADMIN') check internally prepends "ROLE_" to the role name and matches against the granted authorities. So when we store ROLE_ADMIN as a granted authority, hasRole('ADMIN') matches. This is a Spring Security convention that trips up many developers - if you forget the prefix, authorization silently fails.

The filter always calls filterChain.doFilter(), even when there's no token or the token is invalid. This is intentional: unauthenticated requests should still reach GraphQL so that public queries work. Authentication sets up the security context; authorization decides whether to allow or deny, and that happens later in the resolvers.

Step 6: Configure Spring Security

📁 src/main/java/com/graphqlguy/moviedb/config/SecurityConfig.java

package com.graphqlguy.moviedb.config;

import com.graphqlguy.moviedb.security.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthFilter jwtAuthFilter;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

Let's understand each configuration choice:

CSRF is disabled because our API is stateless. CSRF protection guards against attacks where a malicious website submits forms to your server using the victim's session cookie. Since we use JWT tokens (not cookies) and do not keep sessions, CSRF attacks aren't possible against our API.

Session management is STATELESS because JWT tokens carry all authentication information. The server doesn't need to remember sessions between requests, which is simpler and scales better. Any server in a cluster can validate a JWT without consulting a shared session store.

All requests are permitted at the HTTP level. This might seem counterintuitive - why have security if everything is allowed? The answer is that we handle authorization at the method level using @PreAuthorize, not at the URL level. GraphQL has one endpoint, and we need unauthenticated users to reach it for public queries like movies.

@EnableMethodSecurity is critical - without it, @PreAuthorize annotations are silently ignored. This is one of the most common Spring Security mistakes.

Step 7: Create Auth Input Types and Response

Now we need Java records that correspond to the LoginInput and AuthResponse we defined in the schema.

📁 src/main/java/com/graphqlguy/moviedb/user/LoginInput.java

package com.graphqlguy.moviedb.user;

public record LoginInput(String username, String password) {}

📁 src/main/java/com/graphqlguy/moviedb/user/AuthResponse.java

package com.graphqlguy.moviedb.user;

public record AuthResponse(String token, AppUser user) {}

The AuthResponse returns both the JWT token and the user object, so a client has the user's role, username, and email in hand the moment it logs in.

Step 8: Create the UserService

📁 src/main/java/com/graphqlguy/moviedb/user/UserService.java

package com.graphqlguy.moviedb.user;

import com.graphqlguy.moviedb.exception.InvalidInputException;
import com.graphqlguy.moviedb.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;

@Transactional(readOnly = true)
public AuthResponse login(LoginInput input) {
AppUser user = userRepository.findByUsername(input.username())
.orElseThrow(() -> new InvalidInputException("credentials", "Invalid credentials"));
if (!passwordEncoder.matches(input.password(), user.getPassword())) {
throw new InvalidInputException("credentials", "Invalid credentials");
}
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
return new AuthResponse(token, user);
}
}

Two important security practices here:

Password hashing. We never store plain-text passwords. When a user is created - seeded in Step 12, or through the register exercise at the end - passwordEncoder.encode() stores a bcrypt hash; at login, passwordEncoder.matches() compares the submitted password against that hash. Even if the database is compromised, attackers can't recover the original passwords.

Generic, identical errors for login. Both "user not found" and "wrong password" must fail indistinguishably - the same "Invalid credentials" message, the same BAD_REQUEST classification, and the same field. That last part is easy to miss. The handleInvalidInput handler from Class 5 copies field into extensions.field. Throwing InvalidInputException("username", ...) for a non-existent account, and ("password", ...) for a valid account with the wrong password, would leak which case occurred. That hands an attacker the user enumeration signal the matching message was meant to hide, so we throw both with the neutral field "credentials". The rule is that a generic message alone is not enough. Every client-visible signal has to match, message, classification and extensions alike, before the two cases are truly indistinguishable.

Step 9: Create the AuthController

With the service layer complete, we can create the controller that wires up the login operation we defined in the schema.

📁 src/main/java/com/graphqlguy/moviedb/user/AuthController.java

package com.graphqlguy.moviedb.user;

import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;

@Controller
@RequiredArgsConstructor
public class AuthController {

private final UserService userService;

@MutationMapping
AuthResponse login(@Argument LoginInput input) {
return userService.login(input);
}
}

login deliberately has no @PreAuthorize - it must be reachable by unauthenticated callers, since logging in is how you become authenticated. Identifying the current user with an injected Principal (and exposing it through a me query) is left to the exercises, and Class 7 uses that same Principal injection throughout.

Step 10: Protect Mutations with @PreAuthorize

Add @PreAuthorize("hasRole('ADMIN')") to the movie and person write operations. There is a real choice of where it goes - on the controller mapping or on the service method - and the guidance is unanimous: put it on the service. Spring's own reference recommends annotating the service methods rather than the resolvers, and graphql.org makes the same point: keep authorization in the business-logic layer as the single source of truth. So that is where we hang it.

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieService.java - annotate the delete method:

@PreAuthorize("hasRole('ADMIN')")
public DeleteMovieResponse deleteMovie(Long id) {
// ... existing body
}

📁 src/main/java/com/graphqlguy/moviedb/person/PersonService.java - annotate each write method:

@PreAuthorize("hasRole('ADMIN')")
public Person createPerson(CreatePersonInput input) {
// ... existing body
}

@PreAuthorize("hasRole('ADMIN')")
public Person updatePerson(UpdatePersonInput input) {
// ... existing body
}

@PreAuthorize("hasRole('ADMIN')")
public DeletePersonResponse deletePerson(Long id) {
// ... existing body
}

Add the import to each service: import org.springframework.security.access.prepost.PreAuthorize;

The controllers do not change - they stay plain @MutationMapping methods that delegate to the service. That is the whole point of securing the service: the rule holds no matter who calls deletePerson, whether it is this resolver, a future REST endpoint, or a scheduled job. It still works from a GraphQL request, because context propagation carries the Spring Security context from the servlet filter, through the resolver, into the service method. hasRole('ADMIN') therefore sees the authenticated caller, even though the annotation lives a layer deeper than the resolver.

Wait, who else calls the service?

If authorization lives on the service, and a resolver is what resolves a GraphQL field, it is fair to ask how anything could reach the service without a resolver. The answer is anything holding the bean. A resolver is just one inbound adapter - a thin translator that turns an HTTP GraphQL request into a plain personService.deletePerson(id) call. It is the door you met first, not the room itself.

Add a second door and the point turns concrete. Here is an internal admin REST endpoint with no GraphQL anywhere:

@RestController
@RequestMapping("/admin/persons")
@RequiredArgsConstructor
public class AdminPersonRestController {

private final PersonService personService; // the SAME bean the resolver holds

@DeleteMapping("/{id}")
public DeletePersonResponse delete(@PathVariable Long id) {
return personService.deletePerson(id); // @PreAuthorize("hasRole('ADMIN')") still fires
}
}

The guard fires here even though no resolver ran. @EnableMethodSecurity makes Spring wrap PersonService in a proxy, and what gets injected into this controller is that proxy, not the raw service. Calling deletePerson on it crosses the proxy boundary, so the interceptor evaluates hasRole('ADMIN') and denies a non-admin with AccessDeniedException before the method body runs. The caller's identity is populated exactly as it is for GraphQL, because JwtAuthFilter is a servlet filter that runs for every HTTP path, not only /graphql. Had the check lived in the resolver, this endpoint would be a wide-open back door to admin deletes.

You already have a non-resolver caller in the project. DataInitializer (a CommandLineRunner) reaches the data layer on every startup with no resolver and no HTTP request in sight. It calls repositories directly today, so it sits below this boundary. Route one call such as personService.createPerson(input) through a guarded method, though, and it is denied at startup. The interceptor runs, and a startup thread never passed through JwtAuthFilter, so the context is empty and it throws AuthenticationCredentialsNotFoundException. The core distrusts even your own seeder until you install a system principal. A @Scheduled job or a message-queue listener meets the same wall.

Even another service is a caller: when MovieService calls personService.deletePerson, that cross-bean hop re-runs the guard against the current user. Self-invocation is the exception. A service calling its own method through this.method() bypasses @PreAuthorize, the same limitation @Transactional has, so keep a guarded method in a separate bean if you also call it internally.

Authorization on the resolver protects the GraphQL door; authorization on the service protects the room, so it holds no matter which door anyone knocks on, including the ones you have not built yet.

@PreAuthorize is evaluated before the method body executes. If the check fails, Spring raises an AccessDeniedException, and the service code never runs, so there's no risk of accidentally modifying data. What's left is to turn that exception into a clean FORBIDDEN error for the client.

Out of the box, Spring for GraphQL already knows how to classify this. On the servlet stack, Spring Boot auto-registers a SecurityDataFetcherExceptionResolver through GraphQlWebMvcSecurityAutoConfiguration. It maps AccessDeniedException to FORBIDDEN for an authenticated caller, and to UNAUTHORIZED for an anonymous one. If that were the whole story, we would not need to write anything at all.

The catch-all from Class 5 is what gets in the way. Controller-level @GraphQlExceptionHandler methods run before that auto-registered resolver. AccessDeniedException is an Exception, so handleUnexpected claims every denied request, reports INTERNAL_ERROR, and logs the denial as an unexpected crash. Authorization still works, because the guarded code never runs. The client simply cannot tell "you are not allowed" from "the server broke".

The remedy is a dedicated handler, more specific than Exception. Spring resolves exceptions the way Spring MVC does, choosing the closest type in the hierarchy. This handler therefore wins over the catch-all, and restores the FORBIDDEN classification the framework would have supplied. It also lets us control the message the client sees:

📁 src/main/java/com/graphqlguy/moviedb/exception/GlobalExceptionHandler.java - add a handler:

@GraphQlExceptionHandler
public GraphQLError handleAccessDenied(AccessDeniedException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.message("You are not authorized to perform this action")
.errorType(ErrorType.FORBIDDEN)
.build();
}

Add the import: import org.springframework.security.access.AccessDeniedException;.

Notice the handler returns a fixed sentence rather than ex.getMessage(), the same discipline as every other handler - the client learns it was denied, nothing more. And because this handler is more specific than the catch-all, handleUnexpected is back to seeing only genuinely unexpected failures, which is exactly what it was written for.

Step 11: Update Configuration

📁 src/main/resources/application.yaml - add JWT settings:

jwt:
secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000

The secret is a 256-bit hex string (64 hex characters). The expiration is 86,400,000 milliseconds = 24 hours.

Development Only

The hardcoded secret key is fine for development. In production, use an environment variable: secret: ${JWT_SECRET}. Never commit real secrets to source control.

Step 12: Seed Admin User

Update DataInitializer to create demo users:

// At the top of run() method:
userRepository.save(AppUser.builder()
.username("admin").email("[email protected]")
.password(passwordEncoder.encode("admin123")).role(Role.ADMIN).build());
userRepository.save(AppUser.builder()
.username("user").email("[email protected]")
.password(passwordEncoder.encode("user123")).role(Role.USER).build());

You'll need to inject UserRepository and PasswordEncoder into the DataInitializer.

Step 13: Run and Test

Restart your application and open GraphiQL at http://localhost:8080/graphiql.

Login

mutation Login {
login(input: {
username: "admin"
password: "admin123"
}) {
token
user { username role }
}
}

Copy the token from the response - you'll need it for the next test.

Test Protected Mutation Without Auth

mutation CreatePerson {
createPerson(input: {
name: "Unauthorized Person"
birthYear: 1980
}) {
id
}
}

This will return a FORBIDDEN error because there's no authentication token.

Test Protected Mutation With Auth

In GraphiQL, add an HTTP header (there's usually a "Headers" tab at the bottom):

{
"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9..."
}

Use the token from the admin login. Now the same mutation succeeds.

Exercises

Exercise 1: Add a self-service register mutation

The app relies on seeded users right now. Add a public register mutation so anyone can create a USER account and get a token back, exactly like login.

Sketch:

  • Schema: add register(input: RegisterInput!): AuthResponse! under Mutation, plus the input type:
    input RegisterInput {
    username: String!
    email: String!
    password: String!
    }
  • A RegisterInput record next to LoginInput.
  • Two derived-query methods on UserRepository: boolean existsByUsername(String username) and boolean existsByEmail(String email).
  • A register method on UserService that rejects a duplicate username or email with InvalidInputException, hashes the password with passwordEncoder.encode(...), saves the AppUser with Role.USER, and returns an AuthResponse with a fresh token.
  • A @MutationMapping AuthResponse register(@Argument RegisterInput input) on AuthController that delegates to the service. It gets no @PreAuthorize - registration is public.

Then try registering the username admin (already taken): you should get a BAD_REQUEST error carrying a field extension of "username". Note the deliberate asymmetry with login: registration reveals that a username is taken, because it has to; login must not, which is why we scrubbed its error signals down to a neutral "credentials" earlier.

Exercise 2: Add a me query

Add a me: User query that returns the currently authenticated user, or null when no one is logged in.

Sketch:

  • Schema: add me: User under Query.
  • A @QueryMapping AppUser me(Principal principal) on AuthController. Inject java.security.Principal as a method parameter - Spring for GraphQL resolves it from the same security context the JWT filter populated, so you never reach into SecurityContextHolder by hand. Guard the anonymous case (principal == null || "anonymousUser".equals(principal.getName())) and return null; otherwise load the AppUser by principal.getName().

Confirm me returns your user with a token in the header, and null without one - null, not an error, because being logged out is a valid state for this query. This Principal-injection pattern is exactly how Class 7 identifies the caller.

Exercise 3: Understand Token Expiration

Look at your JWT token at jwt.io. Find the exp claim and verify it's 24 hours from now. What happens if you use an expired token?

Beyond role checks: finer-grained authorization

hasRole('ADMIN') is a coarse, all-or-nothing gate on a whole operation. Real systems need finer control. Some rules depend on the data, such as only a review's author or an admin being allowed to delete it. Others protect a single field, such as only the owner or an admin reading another user's email. Class 7 builds both, ownership-based and field-level authorization, on the foundation you laid here.

Common Issues

Issue: All requests return 403

Error: Every query and mutation returns 403 Forbidden Solution: Make sure your SecurityConfig uses auth.anyRequest().permitAll(). If you accidentally used authenticated(), all requests require a token - including public queries and GraphiQL.

Issue: @PreAuthorize does not take effect

Error: Mutations work without authentication even with @PreAuthorize Solution: Add @EnableMethodSecurity to your SecurityConfig class. Without it, Spring Security ignores @PreAuthorize annotations entirely.

Issue: Denials come back as INTERNAL_ERROR

Error: A forbidden mutation returns a generic INTERNAL_ERROR (often with a reference id) instead of FORBIDDEN Solution: Spring Boot does auto-register a SecurityDataFetcherExceptionResolver that would map AccessDeniedException to FORBIDDEN on its own, but the Class 5 catch-all @GraphQlExceptionHandler(Exception.class) runs first and reports INTERNAL_ERROR instead. Add the dedicated handleAccessDenied(AccessDeniedException ex, ...) handler shown above; being more specific than Exception, it wins over the catch-all and restores the proper FORBIDDEN classification.

Summary

In this class, you learned:

  • GraphQL security is per-operation, not per-URL - the same /graphql endpoint serves both public and protected operations, so you secure resolvers and services rather than endpoints
  • JWT tokens provide stateless authentication - the server validates the token's signature without database lookups
  • JwtAuthFilter intercepts every request, extracts the token, and populates Spring Security's context - before GraphQL sees the request
  • @PreAuthorize belongs on the service, not the resolver - Spring's reference and graphql.org both recommend securing the business layer so the rule holds regardless of caller; @EnableMethodSecurity activates it, and context propagation carries the security context down from the request
  • A dedicated AccessDeniedException handler restores the FORBIDDEN classification that Spring Boot's auto-registered SecurityDataFetcherExceptionResolver would provide, so the Class 5 catch-all doesn't shadow denials into INTERNAL_ERROR
  • login is a public mutation that returns a token plus user data; adding self-service register and a me query (with Principal injection) is left as exercises

Further Reading

This chapter's division of labor - authenticate once in a servlet filter, authorize per operation with @PreAuthorize, and keep the authorization policy in Java rather than in the schema - is the pattern the GraphQL ecosystem broadly converges on. These primary sources back it, and show the schema-directive alternative for when you do want authorization surfaced in the SDL:

  • Spring for GraphQL: Security - the official reference for the integration this chapter leans on: injecting Principal into controller methods, propagating the security context to the data-fetching layer, and the auto-registered SecurityDataFetcherExceptionResolver that maps AccessDeniedException to FORBIDDEN (or UNAUTHORIZED for an anonymous caller).
  • Spring Security: Method Security - how @EnableMethodSecurity activates @PreAuthorize/@PostAuthorize, the hasRole shortcut and its ROLE_ prefix convention, and why the annotations are silently inert without it.
  • GraphQL Foundation: Authorization - the official guidance to delegate authorization to the business-logic layer as a single source of truth, plus the @auth type-system-directive alternative and the explicit caveat that a directive only affects execution if the implementation is written to enforce it.
  • GraphQL spec, issue #348: authorization directive - the proposal to standardize a built-in authorization directive, closed as out of scope; the thread is where the "the specification does not care about authorization" position is laid out.
  • Apollo GraphOS: Authorization - a production example of SDL authorization enforced for real: the Router evaluates @authenticated, @requiresScopes, and @policy, filtering unauthorized fields out before it plans the query.
  • AWS AppSync: Authorization and authentication - vendor SDL directives (@aws_auth, @aws_cognito_user_pools) that restrict a field or an entire mutation to a Cognito group, the managed-service analog of an admin-only createPerson.
  • RFC 7519: JSON Web Token - the normative definition of the JWT structure, the registered claims (sub, exp, iat), and the signature validation that our JwtUtil builds and verifies.

What's Next?

In Class 7: Authorization, we go past coarse role checks into finer-grained rules, all kept in the service layer:

  • A review system with ownership rules - only the review's author or an admin can delete it
  • Business rules enforced in the service - you can review a movie, but not twice
  • Field-level authorization - a user's email is readable only by that user or an admin
  • Identifying the caller with an injected Principal, the same pattern from the exercises above