diff --git a/cannamanage-api/pom.xml b/cannamanage-api/pom.xml
index 33c8016..bfa4ee4 100644
--- a/cannamanage-api/pom.xml
+++ b/cannamanage-api/pom.xml
@@ -46,11 +46,57 @@
lombok
true
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.8.6
+
+
+
+ com.h2database
+ h2
+ test
+
+
org.springframework.boot
spring-boot-starter-test
test
+
+ org.springframework.security
+ spring-security-test
+ test
+
diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..ada43eb
--- /dev/null
+++ b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java
@@ -0,0 +1,83 @@
+package de.cannamanage.api.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.net.URI;
+import java.time.Instant;
+
+/**
+ * Global exception handler producing application/problem+json responses.
+ * RFC 9457 compliant.
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BadCredentialsException.class)
+ public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(
+ HttpStatus.UNAUTHORIZED, "Invalid email or password");
+ problem.setTitle("Authentication Failed");
+ problem.setType(URI.create("urn:cannamanage:error:INVALID_CREDENTIALS"));
+ problem.setProperty("code", "INVALID_CREDENTIALS");
+ problem.setProperty("timestamp", Instant.now().toString());
+ return problem;
+ }
+
+ @ExceptionHandler(AccessDeniedException.class)
+ public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(
+ HttpStatus.FORBIDDEN, "Access denied");
+ problem.setTitle("Forbidden");
+ problem.setType(URI.create("urn:cannamanage:error:ACCESS_DENIED"));
+ problem.setProperty("code", "ACCESS_DENIED");
+ problem.setProperty("timestamp", Instant.now().toString());
+ return problem;
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(
+ HttpStatus.BAD_REQUEST, "Validation failed");
+ problem.setTitle("Bad Request");
+ problem.setType(URI.create("urn:cannamanage:error:VALIDATION_FAILED"));
+ problem.setProperty("code", "VALIDATION_FAILED");
+ problem.setProperty("timestamp", Instant.now().toString());
+
+ var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
+ .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
+ .toList();
+ problem.setProperty("errors", fieldErrors);
+ return problem;
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) {
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(
+ HttpStatus.BAD_REQUEST, ex.getMessage());
+ problem.setTitle("Bad Request");
+ problem.setType(URI.create("urn:cannamanage:error:BAD_REQUEST"));
+ problem.setProperty("code", "BAD_REQUEST");
+ problem.setProperty("timestamp", Instant.now().toString());
+ return problem;
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ProblemDetail handleGeneric(Exception ex) {
+ log.error("Unhandled exception", ex);
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(
+ HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
+ problem.setTitle("Internal Server Error");
+ problem.setType(URI.create("urn:cannamanage:error:INTERNAL"));
+ problem.setProperty("code", "INTERNAL_ERROR");
+ problem.setProperty("timestamp", Instant.now().toString());
+ return problem;
+ }
+}
diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java
new file mode 100644
index 0000000..9d92187
--- /dev/null
+++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java
@@ -0,0 +1,82 @@
+package de.cannamanage.api.security;
+
+import de.cannamanage.domain.entity.TenantContext;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * JWT authentication filter.
+ * Extracts Bearer token from Authorization header, validates it,
+ * sets SecurityContext and TenantContext for downstream processing.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private final JwtService jwtService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ final String authHeader = request.getHeader("Authorization");
+
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ final String token = authHeader.substring(7);
+
+ if (!jwtService.isTokenValid(token)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ UUID userId = jwtService.extractUserId(token);
+ UUID tenantId = jwtService.extractTenantId(token);
+ String role = jwtService.extractRole(token);
+
+ // Set tenant context for schema routing
+ TenantContext.setCurrentTenant(tenantId);
+
+ // Build authentication with role-based authority
+ var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
+ var authentication = new UsernamePasswordAuthenticationToken(
+ userId, null, authorities
+ );
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ log.debug("Authenticated user {} for tenant {} with role {}", userId, tenantId, role);
+
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ TenantContext.clear();
+ }
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getServletPath();
+ return path.startsWith("/api/v1/auth/")
+ || path.startsWith("/swagger-ui")
+ || path.startsWith("/v3/api-docs");
+ }
+}
diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java
new file mode 100644
index 0000000..c6b5be9
--- /dev/null
+++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java
@@ -0,0 +1,114 @@
+package de.cannamanage.api.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.SecretKey;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+
+/**
+ * JWT token generation and validation service.
+ * Access tokens: 1 hour expiry.
+ * Refresh tokens: 30 days expiry.
+ */
+@Service
+public class JwtService {
+
+ @Value("${cannamanage.security.jwt.secret}")
+ private String secretKey;
+
+ @Value("${cannamanage.security.jwt.access-token-expiry:3600}")
+ private long accessTokenExpiry; // seconds
+
+ @Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
+ private long refreshTokenExpiry; // seconds (30 days)
+
+ public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
+ return buildToken(Map.of(
+ "tenant_id", tenantId.toString(),
+ "role", role,
+ "email", email
+ ), userId.toString(), accessTokenExpiry);
+ }
+
+ public String generateRefreshToken(UUID userId, UUID tenantId) {
+ return buildToken(Map.of(
+ "tenant_id", tenantId.toString(),
+ "type", "refresh"
+ ), userId.toString(), refreshTokenExpiry);
+ }
+
+ public String extractSubject(String token) {
+ return extractClaim(token, Claims::getSubject);
+ }
+
+ public UUID extractUserId(String token) {
+ return UUID.fromString(extractSubject(token));
+ }
+
+ public UUID extractTenantId(String token) {
+ return UUID.fromString(extractClaim(token, claims -> claims.get("tenant_id", String.class)));
+ }
+
+ public String extractRole(String token) {
+ return extractClaim(token, claims -> claims.get("role", String.class));
+ }
+
+ public String extractEmail(String token) {
+ return extractClaim(token, claims -> claims.get("email", String.class));
+ }
+
+ public boolean isTokenValid(String token) {
+ try {
+ extractAllClaims(token);
+ return !isTokenExpired(token);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean isTokenExpired(String token) {
+ return extractExpiration(token).before(Date.from(Instant.now()));
+ }
+
+ private Date extractExpiration(String token) {
+ return extractClaim(token, Claims::getExpiration);
+ }
+
+ private T extractClaim(String token, Function resolver) {
+ final Claims claims = extractAllClaims(token);
+ return resolver.apply(claims);
+ }
+
+ private Claims extractAllClaims(String token) {
+ return Jwts.parser()
+ .verifyWith(getSigningKey())
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+
+ private String buildToken(Map extraClaims, String subject, long expirySeconds) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .claims(extraClaims)
+ .subject(subject)
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plusSeconds(expirySeconds)))
+ .signWith(getSigningKey())
+ .compact();
+ }
+
+ private SecretKey getSigningKey() {
+ byte[] keyBytes = Decoders.BASE64.decode(secretKey);
+ return Keys.hmacShaKeyFor(keyBytes);
+ }
+}
diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java
new file mode 100644
index 0000000..6e48120
--- /dev/null
+++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java
@@ -0,0 +1,81 @@
+package de.cannamanage.api.security;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+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;
+
+/**
+ * Dual SecurityFilterChain configuration:
+ * - /api/** → stateless JWT (Bearer token)
+ * - /portal/** → session-based (future Sprint 3)
+ */
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtAuthFilter jwtAuthFilter;
+
+ /**
+ * API security — stateless JWT authentication.
+ * All /api/v1/** endpoints require authentication except /api/v1/auth/**.
+ */
+ @Bean
+ @Order(1)
+ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .securityMatcher("/api/**")
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/v1/auth/**").permitAll()
+ .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
+ .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF")
+ .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF")
+ .requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
+ .requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
+ .requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF")
+ .requestMatchers("/api/v1/me/**").authenticated()
+ .anyRequest().authenticated())
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ /**
+ * Public endpoints — Swagger UI, actuator health.
+ */
+ @Bean
+ @Order(2)
+ public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
+ .csrf(csrf -> csrf.disable())
+ .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
+
+ return http.build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+}
diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties
index 490d972..a65b2db 100644
--- a/cannamanage-api/src/main/resources/application.properties
+++ b/cannamanage-api/src/main/resources/application.properties
@@ -2,3 +2,14 @@ spring.application.name=cannamanage
# Default profile — override with -Dspring.profiles.active=local
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=false
+
+# JWT Security
+cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
+cannamanage.security.jwt.access-token-expiry=3600
+cannamanage.security.jwt.refresh-token-expiry=2592000
+
+# OpenAPI
+springdoc.api-docs.path=/v3/api-docs
+springdoc.swagger-ui.path=/swagger-ui.html
+springdoc.swagger-ui.tags-sorter=alpha
+springdoc.swagger-ui.operations-sorter=method