I have seen a lot of teams use Okta or Google login for months before everyone on the project agrees on one basic question: where does the bearer token actually come from, where does it live, and who is responsible for refreshing it? This post is the straight answer I wish I had the first time I had to explain it to a backend team and a frontend team in the same room.
What problem this authentication flow solves
Most modern apps split into three pieces: a browser or mobile client, a Spring Boot middle tier, and an identity provider like Okta or Google. The client should not handle user passwords directly, and your backend should not blindly trust whatever token shows up on the wire.
The point of the flow is simple. Let the identity provider handle login, let Spring Boot validate identity and authorization, and keep users signed in without asking them to re-enter credentials every few minutes.
How the bearer token is generated
The normal pattern here is the Authorization Code flow with PKCE, short for Proof Key for Code Exchange. The important detail is that the user logs in at the provider, not on your backend, and not inside your single-page application.
- The client app redirects the user to the provider's hosted login page.
- The user signs in there, which keeps credentials out of your application stack.
- The provider redirects back with a short-lived authorization code.
- Your Spring Boot backend exchanges that code at the token endpoint.
- The provider returns an access token, a refresh token, and often an ID token.
The access token is the bearer token people usually mean. In many setups it is a JWT, short for JSON Web Token, that says who the user is, which scopes they have, and when the token expires.
POST /oauth/token
grant_type=authorization_code
code=AUTH_CODE
code_verifier=PKCE_VERIFIER
client_id=YOUR_CLIENT_ID
response:
{
"access_token": "eyJ...",
"refresh_token": "def50200...",
"id_token": "eyJ...",
"expires_in": 900
}
Where the token is stored on the client
This is where teams usually get tripped up. The storage model is different for browser apps and native apps, and that difference matters for security.
Browser app
I usually prefer an HttpOnly, Secure cookie. The browser sends it automatically, and JavaScript cannot read it. That cuts down the cross-site scripting, or XSS, blast radius.
Native or mobile app
The app stores the access token and refresh token in secure OS storage like Keychain or Keystore, then sets the Authorization: Bearer <token> header on API calls to the application programming interface, or API.
It helps to separate cookie types from cookie attributes, because people often mix those up in design reviews.
Session cookie
This usually disappears when the browser session ends. It is a good fit when you want the browser to hold only a short-lived session identifier.
Persistent cookie
This includes an expiration time or max age, so it survives browser restarts. Teams use it for remember-me behavior, but you should be careful with long-lived auth state.
HttpOnly cookie
JavaScript cannot read it. That does not stop every attack, but it does make token theft via XSS much harder.
Secure cookie
The browser only sends it over HTTPS. If a cookie carries session or token state, this should be non-negotiable in production.
SameSite cookie
This controls when the browser sends the cookie on cross-site requests. Strict, Lax, and None are the settings you will see most often when dealing with login redirects and cross-site request forgery protection.
Domain and path scoped cookie
These are not separate cookie types, but they matter. They limit where the browser will send the cookie, which helps keep auth state from leaking to the wrong subdomain or route.
For a Spring Boot web app, the common choice is a short-lived session or access cookie marked HttpOnly, Secure, and usually SameSite=Lax unless your cross-site login flow requires something else.
The client does not need to understand token internals. It just needs to store the token in the right place and send it back safely.
How Spring Boot validates authentication on every request
Your Spring Boot API acts as the resource server. Every incoming request has to prove identity again, either with a bearer token header or with a session cookie that maps back to server-side token state.
- Spring Security extracts the token or session context from the request.
- It validates the token signature using the provider's public keys from JWKS, short for JSON Web Key Set.
- It checks expiry, issuer, audience, and scopes or roles.
- It populates the
SecurityContextso controllers and services know who the caller is.
How the SecurityContext gets populated
In a typical Spring Security setup, this happens inside the filter chain before your controller runs. A security filter reads the bearer token or session, authenticates it, builds an Authentication object, and stores that object in SecurityContextHolder.
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
You usually do not write that code yourself for OAuth 2.0 resource server setups. Spring's built-in filters do it for you after a token is validated. For JWT-based auth, that is commonly the BearerTokenAuthenticationFilter working with a JwtAuthenticationProvider. If you use session-based login, another filter restores the authenticated user from the session and puts it back into the context for that request.
The important detail is scope. The SecurityContext is request-bound in normal web apps. It exists so downstream code handling that same request can ask, "Who is calling me, and what are they allowed to do?"
How different application layers use it
Controllers usually use it to get caller identity or to let Spring enforce coarse-grained access rules before business logic runs.
@GetMapping("/api/orders")
@PreAuthorize("hasRole('PHARMACY_USER')")
public List<OrderDto> getOrders(Authentication authentication) {
String subject = authentication.getName();
return orderService.getOrdersForUser(subject);
}
Services use it for business-level authorization. This is where you check things like whether a pharmacist can view any order, but a patient can only view their own order. You can pass the caller identity down explicitly, or read it from the context when that makes the code easier to follow.
@Service
public class OrderService {
@PreAuthorize("hasRole('PHARMACY_USER') or hasRole('PATIENT')")
public List<OrderDto> getOrdersForCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String subject = auth.getName();
return orderRepository.findBySubject(subject);
}
}
Repository and database layers should usually not depend directly on SecurityContext. I try to keep the security decision in the controller or service layer, then pass a concrete user ID, tenant ID, or role-derived filter into the repository. That keeps data access easier to test and easier to reason about.
Controller layer
Read the principal, return the right response shape, and apply endpoint-level rules with annotations like @PreAuthorize.
Service layer
Apply business authorization, ownership checks, tenant boundaries, and role-specific behavior.
Repository layer
Stay focused on data access. Prefer receiving filtered parameters instead of reaching into the security context directly.
If validation fails, the backend should return 401 Unauthorized or 403 Forbidden. It should not call downstream services and hope for the best.
Why refresh tokens exist
Access tokens are intentionally short-lived. That is a feature, not an inconvenience. If someone steals one, the damage window is limited.
The refresh token is what keeps users signed in. When the access token expires, the client or backend uses the refresh token to ask the provider for a new access token. If the refresh token has also expired or has been revoked, the user has to sign in again.
How refresh token rotation works in practice
In a browser-based app, I prefer keeping the refresh token away from JavaScript entirely. The backend stores it securely and ties it to a server-side session. That means the browser only sends an HttpOnly cookie and the backend does the refresh exchange.
- The current access token expires.
- The client hits a refresh endpoint or retries after a
401. - Spring Boot looks up the stored refresh token for that session.
- The backend calls the provider with
grant_type=refresh_token. - The provider returns a new access token and sometimes a new refresh token.
- The backend updates the stored session state and the client continues.
The trade-off: backend-managed refresh tokens are safer for browser apps, but they add server-side session state. If you want fully stateless APIs, you give up some of that control and push more risk to the client.
The full authentication sequence, end to end
This is the sequence I usually draw on a whiteboard. I am keeping the detailed diagram intact because this is the part people refer back to when the implementation starts getting fuzzy.
sequenceDiagram
autonumber
participant User as User (Browser / App)
participant Client as Client App (SPA / Mobile)
participant Backend as Spring Boot API
participant OP as Auth Provider (Okta / Google)
Note over User,OP: INITIAL LOGIN (NO TOKENS YET)
User->>Client: 1. Clicks "Login"
Client->>OP: 2. Redirect to Auth Provider (auth request)
User->>OP: 3. Enter username/password, MFA, consent
OP-->>Client: 4. Redirect back with auth code
Client->>Backend: 5. Send auth code to backend (secure call)
Note over Backend,OP: TOKEN EXCHANGE (ONE-TIME PER LOGIN)
Backend->>OP: 6. Exchange auth code + client secret / PKCE for tokens
OP-->>Backend: 7. Access token (bearer) + refresh token
Backend->>Backend: 8. Store refresh token securely\nmapped to user/session
Backend-->>Client: 9. Set HttpOnly secure session cookie\n(session id or short-lived access token)
Note over Client: TOKEN STORAGE ON CLIENT
Client->>Client: 10. Browser stores HttpOnly cookie only\n(no refresh token in JS/localStorage)
Note over Client,Backend: NORMAL API CALLS\n(NO PER-REQUEST CALL TO OKTA/GOOGLE)
Client->>Backend: 11. Call /api/resource with cookie\nor Authorization: Bearer ACCESS_TOKEN
Backend->>Backend: 12. Validate token locally\n(signature, expiry, audience, scopes)
Backend-->>Client: 13. Return protected data if valid
Note over Backend,OP: OPTIONAL INTROSPECTION (RARE)
Backend->>OP: 14. (Optional) Introspect token at provider
OP-->>Backend: 15. Token active / revoked status
Note over Client,Backend: ACCESS TOKEN EXPIRES
Client->>Backend: 16. Call /api/refresh (session cookie)
Backend->>Backend: 17. Look up stored refresh token\nby user/session
Backend->>OP: 18. Use refresh token to get new access token
OP-->>Backend: 19. New access token\n(+ maybe new refresh token)
Backend->>Backend: 20. Rotate/update stored refresh token\nand session mapping
Backend-->>Client: 21. Update HttpOnly cookie / session\nwith new access token
Note over Client: 22. Continue calling APIs with new access token
What I would avoid
I would not put refresh tokens in localStorage for a browser app. I would not skip audience and issuer checks just because the JWT signature validates. I would also avoid treating token refresh as a frontend-only concern when the backend is the one that actually owns the trust boundary.
I have seen systems where the login story looked fine in demos but broke down under incident pressure because nobody could answer where refresh state lived or how revocation worked. Those details matter.
FAQ: common Spring Boot authentication questions
What is the difference between an ID token and an access token?
The ID token describes the authenticated user to the client. The access token is what your API uses to authorize requests. They are not interchangeable.
Does Spring Boot call Okta or Google on every request?
Usually no. Most setups validate JWTs locally with cached provider keys. Token introspection exists, but it is the exception rather than the normal path.
What do the acronyms in this flow mean?
PKCE means Proof Key for Code Exchange. JWT means JSON Web Token. JWKS means JSON Web Key Set. SPA means single-page application. OIDC means OpenID Connect. MFA means multi-factor authentication. API means application programming interface. XSS means cross-site scripting.
Should a Spring Boot app be stateless if it uses refresh tokens?
Not always. If the backend stores refresh tokens securely for browser sessions, you now have state. That is often a good trade when you want tighter control over refresh and revocation behavior.
When should the user be forced to log in again?
When the refresh token is expired, revoked, or missing, the refresh flow fails and the user needs a full re-authentication redirect.