Back to Insights
Spring Boot Authentication

How Authentication Actually Works in a Spring Boot App

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.

Client app: browser single-page application or mobile app Middle tier: Java Spring Boot backend Auth provider: Okta, Google, or another OpenID Connect provider

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.

  1. The client app redirects the user to the provider's hosted login page.
  2. The user signs in there, which keeps credentials out of your application stack.
  3. The provider redirects back with a short-lived authorization code.
  4. Your Spring Boot backend exchanges that code at the token endpoint.
  5. 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.

  1. Spring Security extracts the token or session context from the request.
  2. It validates the token signature using the provider's public keys from JWKS, short for JSON Web Key Set.
  3. It checks expiry, issuer, audience, and scopes or roles.
  4. It populates the SecurityContext so 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.

  1. The current access token expires.
  2. The client hits a refresh endpoint or retries after a 401.
  3. Spring Boot looks up the stored refresh token for that session.
  4. The backend calls the provider with grant_type=refresh_token.
  5. The provider returns a new access token and sometimes a new refresh token.
  6. 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.