OAuth 2.0 Fundamentals: Authorization Code & Access Token
OAuth 2.0 is the industry standard for delegated authorization. It lets applications access resources on behalf of a user without exposing user credentials.
What is OAuth 2.0?
OAuth 2.0 is an authorization framework that allows third-party applications to obtain limited access to protected resources without exchanging user credentials directly.
Core Concepts of OAuth 2.0
- Delegation: Users grant applications access to their resources
- Token-based: Tokens are used instead of passwords
- Scope-based: Access is confined to defined scopes
- Secure: Reduces attack surface by using short-lived tokens with limited permissions
OAuth 2.0 Roles
The Four Key Players
// OAuth 2.0 Roles as Java Classes
public class OAuthRoles {
// Resource Owner: The user who owns the resource
public static class ResourceOwner {
private String userId;
private String username;
private List<String> ownedResources;
public ResourceOwner(String userId, String username) {
this.userId = userId;
this.username = username;
this.ownedResources = new ArrayList<>();
}
public boolean ownsResource(String resourceId) {
return ownedResources.contains(resourceId);
}
// Grants or denies access
public boolean authorizeAccess(String clientId, List<String> scopes) {
// Business logic for authorization
return true; // Simplified
}
}
// Resource Server: Hosts the protected resources
public static class ResourceServer {
private Map<String, ProtectedResource> resources;
private TokenValidator tokenValidator;
public ResourceServer() {
this.resources = new HashMap<>();
this.tokenValidator = new JWTTokenValidator();
}
public ProtectedResource getResource(String resourceId, String accessToken) {
if (!tokenValidator.isValid(accessToken)) {
throw new UnauthorizedException("Invalid token");
}
TokenInfo tokenInfo = tokenValidator.getTokenInfo(accessToken);
if (!tokenInfo.hasScope("read")) {
throw new ForbiddenException("Insufficient scope");
}
ProtectedResource resource = resources.get(resourceId);
if (resource == null) {
throw new NotFoundException("Resource not found");
}
return resource;
}
}
// Authorization Server: Authenticates the user and issues tokens
public static class AuthorizationServer {
private ClientRegistry clientRegistry;
private UserRegistry userRegistry;
private TokenService tokenService;
public AuthorizationServer() {
this.clientRegistry = new ClientRegistry();
this.userRegistry = new UserRegistry();
this.tokenService = new JWTTokenService();
}
public AuthorizationCode generateAuthorizationCode(
String clientId, String userId, List<String> scopes) {
if (!clientRegistry.isValidClient(clientId)) {
throw new InvalidClientException("Unknown client");
}
AuthorizationCode code = new AuthorizationCode(
UUID.randomUUID().toString(),
clientId,
userId,
scopes,
Instant.now().plusSeconds(600) // Valid for 10 minutes
);
return code;
}
public TokenResponse exchangeCodeForTokens(String code, String clientId, String clientSecret) {
AuthorizationCode authCode = validateAuthorizationCode(code, clientId);
if (!clientRegistry.authenticateClient(clientId, clientSecret)) {
throw new InvalidClientException("Authentication failed");
}
// Create Access Token and Refresh Token
String accessToken = tokenService.createAccessToken(
authCode.getUserId(),
authCode.getScopes()
);
String refreshToken = tokenService.createRefreshToken(
authCode.getUserId()
);
return new TokenResponse(accessToken, refreshToken, 3600, "Bearer");
}
private AuthorizationCode validateAuthorizationCode(String code, String clientId) {
// Implementation to validate the authorization code
return new AuthorizationCode(code, clientId, "user123",
Arrays.asList("read", "write"), Instant.now());
}
}
// Client: The application requesting access
public static class Client {
private String clientId;
private String clientSecret;
private List<String> redirectUris;
private List<String> allowedScopes;
public Client(String clientId, String clientSecret) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUris = new ArrayList<>();
this.allowedScopes = Arrays.asList("read", "write", "profile");
}
public String initiateAuthorizationFlow(List<String> requestedScopes) {
// Create an authorization request
String authUrl = String.format(
"https://auth.example.com/authorize?" +
"response_type=code&" +
"client_id=%s&" +
"redirect_uri=%s&" +
"scope=%s&" +
"state=%s",
clientId,
"https://client.example.com/callback",
String.join(" ", requestedScopes),
UUID.randomUUID().toString()
);
return authUrl;
}
public TokenResponse exchangeCodeForTokens(String code) {
// Send token request to authorization server
return tokenService.exchangeCode(code, clientId, clientSecret);
}
}
}
Authorization Code Flow
The Most Secure OAuth 2.0 Flow
public class AuthorizationCodeFlow {
// Step 1: Authorization Request
public String buildAuthorizationRequest() {
StringBuilder request = new StringBuilder("https://auth.example.com/authorize?");
request.append("response_type=code");
request.append("&client_id=client123");
request.append("&redirect_uri=https://client.example.com/callback");
request.append("&scope=read%20write%20profile");
request.append("&state=xyz123"); // CSRF Protection
return request.toString();
}
// Step 2: User Authorization
public void handleUserAuthorization(String userId, String clientId, List<String> scopes) {
// User is redirected to the login page
// After successful authentication:
if (userConsentsToScopes(userId, scopes)) {
AuthorizationCode code = authorizationServer.generateAuthorizationCode(
clientId, userId, scopes
);
// Redirect with authorization code
String redirectUri = String.format(
"https://client.example.com/callback?code=%s&state=xyz123",
code.getValue()
);
redirectToClient(redirectUri);
} else {
// User denied access
redirectToClient("https://client.example.com/callback?error=access_denied");
}
}
// Step 3: Token Exchange
public TokenResponse exchangeCodeForTokens(String code, String state) {
// Validate state (CSRF Protection)
if (!isValidState(state)) {
throw new SecurityException("Invalid state parameter");
}
// Send token request to authorization server
Map<String, String> tokenRequest = new HashMap<>();
tokenRequest.put("grant_type", "authorization_code");
tokenRequest.put("code", code);
tokenRequest.put("redirect_uri", "https://client.example.com/callback");
tokenRequest.put("client_id", "client123");
tokenRequest.put("client_secret", "secret123");
// Send HTTP POST request
TokenResponse response = httpClient.postForm(
"https://auth.example.com/token",
tokenRequest
);
return response;
}
// Step 4: Resource Access
public ProtectedResource accessResource(String accessToken, String resourceId) {
// Send access token in Authorization header
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return httpClient.get(
"https://api.example.com/resources/" + resourceId,
headers
);
}
}
Spring Security OAuth 2.0 Implementation
@Configuration
@EnableWebSecurity
public class OAuth2SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/private/**").authenticated()
.anyRequest().denyAll()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/my-client")
.authorizationEndpoint(authorization -> authorization
.baseUri("/oauth2/authorize")
)
.redirectionEndpoint(redirection -> redirection
.baseUri("/oauth2/callback/*")
)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
)
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.build();
}
@Bean
public Converter<Jwt, UsernamePasswordAuthenticationToken> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> customOAuth2UserService() {
return new CustomOAuth2UserService();
}
}
// Custom OAuth2 User Service
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// Process user information and persist to database
String provider = userRequest.getClientRegistration().getRegistrationId();
String providerId = oAuth2User.getAttribute("id");
User user = findOrCreateUser(provider, providerId, oAuth2User);
return new CustomOAuth2User(user, oAuth2User.getAttributes());
}
private User findOrCreateUser(String provider, String providerId, OAuth2User oAuth2User) {
// Logic to find or create a user
return userRepository.findByProviderAndProviderId(provider, providerId)
.orElseGet(() -> createUser(provider, providerId, oAuth2User));
}
private User createUser(String provider, String providerId, OAuth2User oAuth2User) {
User user = new User();
user.setProvider(provider);
user.setProviderId(providerId);
user.setEmail(oAuth2User.getAttribute("email"));
user.setName(oAuth2User.getAttribute("name"));
user.setRoles(Arrays.asList("USER"));
return userRepository.save(user);
}
}
Token Management
JWT Token Implementation
@Component
public class JWTTokenService {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private int jwtExpiration;
@Value("${jwt.refresh-expiration}")
private int refreshExpiration;
public String createAccessToken(String userId, List<String> scopes) {
return createToken(userId, scopes, jwtExpiration);
}
public String createRefreshToken(String userId) {
return createToken(userId, Arrays.asList("refresh"), refreshExpiration);
}
private String createToken(String userId, List<String> scopes, int expiration) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(expiration);
return Jwts.builder()
.setSubject(userId)
.claim("scopes", scopes)
.claim("type", scopes.contains("refresh") ? "refresh" : "access")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(SignatureAlgorithm.HS256, jwtSecret)
.compact();
}
public Claims validateToken(String token) {
try {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new TokenExpiredException("Token expired");
} catch (JwtException e) {
throw new InvalidTokenException("Invalid token");
}
}
public String refreshToken(String refreshToken) {
Claims claims = validateToken(refreshToken);
if (!"refresh".equals(claims.get("type"))) {
throw new InvalidTokenException("Not a refresh token");
}
String userId = claims.getSubject();
List<String> scopes = Arrays.asList("read", "write"); // Default scopes
return createAccessToken(userId, scopes);
}
}
// Token Controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JWTTokenService tokenService;
@PostMapping("/token")
public ResponseEntity<TokenResponse> exchangeCodeForToken(
@RequestBody TokenRequest tokenRequest) {
try {
// Validate authorization code
AuthorizationCode code = validateAuthorizationCode(tokenRequest.getCode());
// Generate tokens
String accessToken = tokenService.createAccessToken(
code.getUserId(),
code.getScopes()
);
String refreshToken = tokenService.createRefreshToken(code.getUserId());
TokenResponse response = new TokenResponse(
accessToken,
refreshToken,
3600,
"Bearer"
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("invalid_grant", e.getMessage()));
}
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refreshToken(
@RequestBody RefreshTokenRequest request) {
try {
String newAccessToken = tokenService.refreshToken(request.getRefreshToken());
TokenResponse response = new TokenResponse(
newAccessToken,
request.getRefreshToken(),
3600,
"Bearer"
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("invalid_grant", e.getMessage()));
}
}
@PostMapping("/revoke")
public ResponseEntity<Void> revokeToken(@RequestBody RevokeTokenRequest request) {
// Add token to blacklist
tokenBlacklist.addToBlacklist(request.getToken());
return ResponseEntity.ok().build();
}
}
Token Blacklist
@Service
public class TokenBlacklist {
private final RedisTemplate<String, String> redisTemplate;
private static final String BLACKLIST_PREFIX = "blacklist:";
public TokenBlacklist(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void addToBlacklist(String token) {
String jti = extractJti(token);
long expiration = extractExpiration(token);
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + jti,
"revoked",
Duration.ofSeconds(expiration - System.currentTimeMillis() / 1000)
);
}
public boolean isBlacklisted(String token) {
String jti = extractJti(token);
return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + jti));
}
private String extractJti(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.getId();
}
private long extractExpiration(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().getTime() / 1000;
}
}
OAuth 2.0 Security Best Practices
1. State Parameter (CSRF Protection)
public class StateManager {
public String generateState() {
return UUID.randomUUID().toString();
}
public void storeState(String state, String sessionId) {
// Store state in session or Redis
redisTemplate.opsForValue().set(
"oauth2_state:" + state,
sessionId,
Duration.ofMinutes(10)
);
}
public boolean validateState(String state, String sessionId) {
String storedSessionId = redisTemplate.opsForValue()
.get("oauth2_state:" + state);
if (storedSessionId == null) {
return false; // State expired or not found
}
if (!storedSessionId.equals(sessionId)) {
return false; // State mismatch
}
// Delete state after use
redisTemplate.delete("oauth2_state:" + state);
return true;
}
}
2. PKCE (Proof Key for Code Exchange)
public class PKCEManager {
public PKCEPair generatePKCEPair() {
String codeVerifier = generateCodeVerifier();
String codeChallenge = generateCodeChallenge(codeVerifier);
return new PKCEPair(codeVerifier, codeChallenge);
}
private String generateCodeVerifier() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
}
private String generateCodeChallenge(String codeVerifier) {
byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(bytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest);
}
public boolean verifyCodeChallenge(String codeVerifier, String codeChallenge) {
String computedChallenge = generateCodeChallenge(codeVerifier);
return computedChallenge.equals(codeChallenge);
}
}
3. Secure Token Storage
@Component
public class SecureTokenStorage {
@Value("${token.encryption.key}")
private String encryptionKey;
public void storeTokens(String sessionId, TokenResponse tokens) {
// Store tokens encrypted
String encryptedAccessToken = encrypt(tokens.getAccessToken());
String encryptedRefreshToken = encrypt(tokens.getRefreshToken());
TokenStorage storage = new TokenStorage(
encryptedAccessToken,
encryptedRefreshToken,
Instant.now().plusSeconds(tokens.getExpiresIn())
);
redisTemplate.opsForValue().set(
"tokens:" + sessionId,
storage,
Duration.ofDays(30)
);
}
public TokenResponse getTokens(String sessionId) {
TokenStorage storage = redisTemplate.opsForValue()
.get("tokens:" + sessionId);
if (storage == null) {
return null;
}
String accessToken = decrypt(storage.getAccessToken());
String refreshToken = decrypt(storage.getRefreshToken());
return new TokenResponse(
accessToken,
refreshToken,
storage.getExpiresIn(),
"Bearer"
);
}
private String encrypt(String data) {
// AES implementation
return AESTextEncryption.encrypt(data, encryptionKey);
}
private String decrypt(String encryptedData) {
// AES implementation
return AESTextEncryption.decrypt(encryptedData, encryptionKey);
}
}
OpenID Connect (OIDC)
OIDC Integration
@Configuration
public class OIDCConfig {
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
// OIDC User Info Service
@Service
public class OIDCUserInfoService {
public OIDCUserInfo getUserInfo(String accessToken) {
// Call User Info endpoint
String userInfoUrl = "https://auth.example.com/oauth2/userinfo";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<OIDCUserInfo> response = restTemplate.exchange(
userInfoUrl,
HttpMethod.GET,
entity,
OIDCUserInfo.class
);
return response.getBody();
}
}
// OIDC User Info DTO
public class OIDCUserInfo {
private String sub;
private String name;
private String email;
private String picture;
private List<String> roles;
// Getters and setters
public String getSubject() { return sub; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getPicture() { return picture; }
public List<String> getRoles() { return roles; }
}
OAuth 2.0 Client Implementation
JavaScript Client Example
class OAuth2Client {
constructor(config) {
this.clientId = config.clientId;
this.redirectUri = config.redirectUri;
this.authUrl = config.authUrl;
this.tokenUrl = config.tokenUrl;
this.scopes = config.scopes;
}
// Initiate authorization request
initiateAuthorization() {
const state = this.generateState();
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallenge(codeVerifier);
// Store state and code verifier
sessionStorage.setItem('oauth2_state', state);
sessionStorage.setItem('oauth2_code_verifier', codeVerifier);
const authUrl = new URL(this.authUrl);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', this.clientId);
authUrl.searchParams.set('redirect_uri', this.redirectUri);
authUrl.searchParams.set('scope', this.scopes.join(' '));
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Redirect to authorization server
window.location.href = authUrl.toString();
}
// Handle callback
async handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const storedState = sessionStorage.getItem('oauth2_state');
// Validate state
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
// Exchange code for tokens
const codeVerifier = sessionStorage.getItem('oauth2_code_verifier');
const tokenResponse = await this.exchangeCodeForTokens(code, codeVerifier);
// Store tokens
localStorage.setItem('access_token', tokenResponse.access_token);
localStorage.setItem('refresh_token', tokenResponse.refresh_token);
// Clean up
sessionStorage.removeItem('oauth2_state');
sessionStorage.removeItem('oauth2_code_verifier');
return tokenResponse;
}
// Token exchange
async exchangeCodeForTokens(code, codeVerifier) {
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: codeVerifier
})
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return await response.json();
}
// Refresh access token
async refreshAccessToken() {
const refreshToken = localStorage.getItem('refresh_token');
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId
})
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const tokenResponse = await response.json();
localStorage.setItem('access_token', tokenResponse.access_token);
return tokenResponse;
}
// Make authenticated API request
async makeAuthenticatedRequest(url, options = {}) {
let accessToken = localStorage.getItem('access_token');
// Check token and refresh if needed
if (this.isTokenExpired(accessToken)) {
await this.refreshAccessToken();
accessToken = localStorage.getItem('access_token');
}
const headers = {
'Authorization': `Bearer ${accessToken}`,
...options.headers
};
return fetch(url, {
...options,
headers
});
}
// Helper methods
generateState() {
return Math.random().toString(36).substring(2, 15);
}
generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode.apply(null, array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
isTokenExpired(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return Date.now() >= payload.exp * 1000;
} catch {
return true;
}
}
}
// Usage
const oauth2Client = new OAuth2Client({
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback',
authUrl: 'https://auth.example.com/oauth2/authorize',
tokenUrl: 'https://auth.example.com/oauth2/token',
scopes: ['read', 'write', 'profile']
});
// Initiate login
document.getElementById('login-btn').addEventListener('click', () => {
oauth2Client.initiateAuthorization();
});
// API request
document.getElementById('fetch-data-btn').addEventListener('click', async () => {
try {
const response = await oauth2Client.makeAuthenticatedRequest(
'https://api.example.com/user/profile'
);
const data = await response.json();
console.log('User data:', data);
} catch (error) {
console.error('API request failed:', error);
}
});
Key Exam Concepts
OAuth 2.0 Flow Types
- Authorization Code Flow: The most secure approach for web applications
- Implicit Flow: Deprecated and no longer recommended
- Client Credentials Flow: For server-to-server communication
- Resource Owner Password Credentials: Only for trusted applications
Security Considerations
- State Parameter: CSRF protection
- PKCE: Defense against authorization code interception
- Token Storage: Secure token persistence
- HTTPS: Required for encrypted communication
- Scope Limitation: Request minimum necessary permissions
Typical Exam Questions
- Explain the Authorization Code Flow
- Compare OAuth 2.0 with OpenID Connect
- Implement a simple OAuth 2.0 Client
- Describe security risks and mitigation strategies
Summary
OAuth 2.0 is a powerful framework for delegating authorization:
- Secure: Token-based authentication instead of passwords
- Flexible: Different flows for different use cases
- Standardized: Widely adopted and well-supported
- Extensible: OpenID Connect for identity management
Proper implementation requires careful attention to security aspects such as state parameters, PKCE, and secure token storage.
Next in the API Learning Path
The next article in the API Learning Path covers OAuth2 and OpenID Connect for APIs — how to combine OAuth2 with OpenID Connect and integrate authentication and authorization in APIs.

