<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: TeKa IT</title>
    <description>The latest articles on DEV Community by TeKa IT (@tekait).</description>
    <link>https://dev.to/tekait</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4055155%2F336d1b1c-3fcb-46f5-b608-9e3e99ee08c2.png</url>
      <title>DEV Community: TeKa IT</title>
      <link>https://dev.to/tekait</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tekait"/>
    <language>en</language>
    <item>
      <title>Why You Should Hash Refresh Tokens with SHA-256</title>
      <dc:creator>TeKa IT</dc:creator>
      <pubDate>Sat, 03 Oct 2026 11:31:46 +0000</pubDate>
      <link>https://dev.to/tekait/why-you-should-hash-refresh-tokens-with-sha-256-2afj</link>
      <guid>https://dev.to/tekait/why-you-should-hash-refresh-tokens-with-sha-256-2afj</guid>
      <description>&lt;p&gt;Refresh tokens are often treated as an implementation detail.&lt;/p&gt;

&lt;p&gt;Generate a random string, store it in the database, return it to the client, and use it later to issue a new access token.&lt;/p&gt;

&lt;p&gt;It works.&lt;/p&gt;

&lt;p&gt;But there is an important security question hiding in that design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if someone gets read access to your refresh-token database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the database contains the actual refresh tokens, the answer is uncomfortable.&lt;/p&gt;

&lt;p&gt;An attacker who can read the database may be able to use those tokens immediately.&lt;/p&gt;

&lt;p&gt;That is why refresh tokens should generally be treated like other authentication secrets: &lt;strong&gt;the server should be able to verify them without storing the original value in plaintext.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In this article, we'll build that idea from first principles and implement it with SHA-256.&lt;/p&gt;

&lt;p&gt;We'll look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;why storing refresh tokens in plaintext is risky&lt;/li&gt;
&lt;li&gt;why hashing is useful for refresh tokens&lt;/li&gt;
&lt;li&gt;why SHA-256 is appropriate for high-entropy tokens&lt;/li&gt;
&lt;li&gt;why BCrypt is the wrong tool for this particular job&lt;/li&gt;
&lt;li&gt;how to generate cryptographically random refresh tokens&lt;/li&gt;
&lt;li&gt;how to hash and validate them in Spring Boot&lt;/li&gt;
&lt;li&gt;how hashing fits together with refresh-token rotation&lt;/li&gt;
&lt;li&gt;what hashing does — and does not — protect you from&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important distinction throughout this article is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Passwords and refresh tokens are both secrets, but they have very different security properties.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. A Refresh Token Is a Bearer Secret
&lt;/h2&gt;

&lt;p&gt;Consider a typical authentication flow.&lt;/p&gt;

&lt;p&gt;A user logs in and receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;access token
refresh token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The access token might be a JWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiJ9...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refresh token is usually just an opaque random value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V4Z8v2mQ4Qm7Qe7z...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client later sends the refresh token to an endpoint such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/auth/refresh
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server validates it and issues a new access token.&lt;/p&gt;

&lt;p&gt;The important property is that the refresh token is a &lt;strong&gt;bearer credential&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Whoever possesses a valid refresh token can potentially use it.&lt;/p&gt;

&lt;p&gt;There is no username and password challenge attached to the token.&lt;/p&gt;

&lt;p&gt;There is no proof that the person presenting the token is the original user.&lt;/p&gt;

&lt;p&gt;Possession is the credential.&lt;/p&gt;

&lt;p&gt;That means this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refresh token = authentication secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that leads directly to the database question.&lt;/p&gt;

&lt;p&gt;If the database contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V4Z8v2mQ4Qm7Qe7z...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then anyone who obtains that value may have obtained an authentication credential.&lt;/p&gt;

&lt;p&gt;A database dump therefore becomes more than a data confidentiality problem.&lt;/p&gt;

&lt;p&gt;It can become a session hijacking problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Why Plaintext Storage Is Unnecessary
&lt;/h2&gt;

&lt;p&gt;Suppose the server generates this refresh token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RANDOM_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RANDOM_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the database does not actually need to know the original value.&lt;/p&gt;

&lt;p&gt;The server only needs to answer one question later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Does the refresh token presented by the client correspond to a valid token stored in the database?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means we can store a one-way representation instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RANDOM_SECRET
      |
      v
   SHA-256
      |
      v
HASHED_VALUE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client later sends the refresh token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RANDOM_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the server performs the same transformation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RANDOM_SECRET
      |
      v
   SHA-256
      |
      v
HASHED_VALUE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it compares the resulting hash with the value stored in the database.&lt;/p&gt;

&lt;p&gt;The original refresh token never needs to be stored.&lt;/p&gt;

&lt;p&gt;This changes the security properties of a database compromise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plaintext storage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database:

refresh_token
--------------------------------
V4Z8v2mQ4Qm7Qe7z...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an attacker reads the database, they have the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hashed storage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database:

token_hash
----------------------------------------------------------------
9f86d081884c7d659a2feaa0c55ad015...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database contains a verifier rather than the credential itself.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;OWASP's session-management guidance explicitly describes storing a one-way verifier for random session tokens when read-only disclosure of the session store is part of the threat model. It also notes that fast hashing is sufficient for these random verifiers.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. But Why SHA-256?
&lt;/h2&gt;

&lt;p&gt;This is where refresh tokens differ from passwords.&lt;/p&gt;

&lt;p&gt;A common reaction is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We're storing something sensitive. Shouldn't we use BCrypt?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not necessarily.&lt;/p&gt;

&lt;p&gt;In fact, using BCrypt for refresh tokens is usually solving the wrong problem.&lt;/p&gt;

&lt;p&gt;To understand why, we need to look at the difference between a password and a randomly generated token.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Passwords Are Low-Entropy Secrets
&lt;/h2&gt;

&lt;p&gt;Passwords are chosen by humans.&lt;/p&gt;

&lt;p&gt;That creates a problem.&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;password123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker can make educated guesses.&lt;/p&gt;

&lt;p&gt;They can try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;password
password1
password123
qwerty
letmein
summer2026
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even a reasonably long human-generated password may have much less entropy than its length suggests.&lt;/p&gt;

&lt;p&gt;That's why password hashing algorithms deliberately make every guess expensive.&lt;/p&gt;

&lt;p&gt;BCrypt, Argon2 and PBKDF2 are designed to make password guessing computationally expensive.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;password
    |
    v
   BCrypt
    |
    v
stored password hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost of BCrypt is a feature.&lt;/p&gt;

&lt;p&gt;You want attackers to pay a significant computational price for every password guess.&lt;/p&gt;

&lt;p&gt;Spring Security's &lt;code&gt;BCryptPasswordEncoder&lt;/code&gt; is specifically designed around this model and deliberately uses a work factor to make password hashing expensive.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Refresh Tokens Should Have High Entropy
&lt;/h2&gt;

&lt;p&gt;A properly generated refresh token has a very different property.&lt;/p&gt;

&lt;p&gt;It should not be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refresh-token-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user-42-refresh-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a8f3c91
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, generate it using a cryptographically secure random number generator.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SecureRandom&lt;/span&gt; &lt;span class="n"&gt;secureRandom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SecureRandom&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
&lt;span class="n"&gt;secureRandom&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nextBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlEncoder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withoutPadding&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encodeToString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we generate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;32 random bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;256 bits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;of random input.&lt;/p&gt;

&lt;p&gt;The important property isn't the string length.&lt;/p&gt;

&lt;p&gt;It's the entropy.&lt;/p&gt;

&lt;p&gt;If the token is generated correctly, an attacker cannot realistically predict the next valid token.&lt;/p&gt;

&lt;p&gt;OWASP's session-management guidance similarly emphasizes that reference tokens should be generated using a cryptographically secure random number generator and have sufficient entropy.&lt;/p&gt;

&lt;p&gt;So the security problem is no longer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How do we make every hash calculation expensive?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How do we prevent someone who can read our database from immediately obtaining the original bearer secret?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's exactly where SHA-256 fits.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. SHA-256 Is Fast — and That's Fine Here
&lt;/h2&gt;

&lt;p&gt;SHA-256 is designed to be fast.&lt;/p&gt;

&lt;p&gt;For passwords, that is a problem.&lt;/p&gt;

&lt;p&gt;For a high-entropy random token, it is not.&lt;/p&gt;

&lt;p&gt;Consider the two scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Password
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;human password
      |
      v
slow password hash
      |
      v
database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slow algorithm makes offline guessing expensive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refresh token
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;256-bit random token
      |
      v
SHA-256
      |
      v
database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token itself is already designed to make guessing infeasible.&lt;/p&gt;

&lt;p&gt;SHA-256 does not need to make guesses expensive because there should be no realistic sequence of guesses to make.&lt;/p&gt;

&lt;p&gt;This is an important security principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The correct hashing algorithm depends on the properties of the secret being protected.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Don't blindly apply password-storage rules to every secret.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. SHA-256 vs BCrypt
&lt;/h2&gt;

&lt;p&gt;The difference becomes clearer when we compare them directly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;Refresh Token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Usually created by&lt;/td&gt;
&lt;td&gt;Human&lt;/td&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Predictability&lt;/td&gt;
&lt;td&gt;Potentially high&lt;/td&gt;
&lt;td&gt;Should be extremely low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entropy&lt;/td&gt;
&lt;td&gt;Variable&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brute-force resistance&lt;/td&gt;
&lt;td&gt;Needs slow hashing&lt;/td&gt;
&lt;td&gt;Primarily comes from randomness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appropriate hashing strategy&lt;/td&gt;
&lt;td&gt;Argon2id / BCrypt / PBKDF2&lt;/td&gt;
&lt;td&gt;Fast cryptographic hash such as SHA-256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main goal&lt;/td&gt;
&lt;td&gt;Make guessing expensive&lt;/td&gt;
&lt;td&gt;Store a verifier without storing the secret&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BCrypt is not "more secure" simply because it is slower.&lt;/p&gt;

&lt;p&gt;It is optimized for a different problem.&lt;/p&gt;

&lt;p&gt;Using BCrypt for a 256-bit random refresh token can add substantial computation without meaningfully improving the protection provided by the token's already-high entropy.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Hash the Token Before Storing It
&lt;/h2&gt;

&lt;p&gt;The implementation can be very small.&lt;/p&gt;

&lt;p&gt;A dedicated service keeps the hashing logic out of your controllers and authentication flow.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.charset.StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.MessageDigest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.NoSuchAlgorithmException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.HexFormat&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenHasher&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;RefreshTokenHasher&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;MessageDigest&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HexFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;formatHex&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoSuchAlgorithmException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"SHA-256 is not available"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;e&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A SHA-256 digest contains 256 bits.&lt;/p&gt;

&lt;p&gt;When represented as hexadecimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;256 bits / 4 bits per hex character = 64 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a database column can look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tokenHash&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual refresh token should never be persisted.&lt;/p&gt;

&lt;p&gt;Only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokenHash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is stored.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. The Complete Token Lifecycle
&lt;/h2&gt;

&lt;p&gt;Let's put the pieces together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Generate
&lt;/h3&gt;

&lt;p&gt;At login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SecureRandom
    |
    v
256-bit random refresh token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;oW8n...random...value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 — Hash
&lt;/h3&gt;

&lt;p&gt;The server calculates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SHA-256(refreshToken)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9f86d081884c7d659a2feaa0c55ad015...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — Store
&lt;/h3&gt;

&lt;p&gt;The database receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;token_hash
expires_at
revoked
user_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; receive the original token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Return
&lt;/h3&gt;

&lt;p&gt;The raw refresh token is returned to the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refreshToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oW8n...random...value"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server no longer needs to persist that raw value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Refresh
&lt;/h3&gt;

&lt;p&gt;Later, the client sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refreshToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oW8n...random...value"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server calculates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SHA-256(receivedToken)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then searches for that hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT *
FROM refresh_tokens
WHERE token_hash = ?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the record exists and is valid, the refresh operation can continue.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Validation Is More Than Checking the Hash
&lt;/h2&gt;

&lt;p&gt;A matching hash does not automatically mean that the refresh token should be accepted.&lt;/p&gt;

&lt;p&gt;A production implementation should also validate the token's state.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tokenHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refreshTokenHasher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="n"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByTokenHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenHash&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidRefreshTokenException&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRevoked&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidRefreshTokenException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidRefreshTokenException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 refresh token
                       |
                       v
                  SHA-256
                       |
                       v
              find token record
                       |
          +------------+------------+
          |            |            |
       exists?       revoked?    expired?
          |            |            |
          v            v            v
         yes           no           no
                       |
                       v
                  accept token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one reason refresh tokens are often stored server-side even when access tokens are JWTs.&lt;/p&gt;

&lt;p&gt;The database record provides server-controlled state.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Hashing and Rotation Solve Different Problems
&lt;/h2&gt;

&lt;p&gt;Hashing is only one part of a secure refresh-token design.&lt;/p&gt;

&lt;p&gt;Consider this attack.&lt;/p&gt;

&lt;p&gt;An attacker somehow steals a valid refresh token from a browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ATTACKER
   |
   | stolen refresh token
   v
POST /api/auth/refresh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hashing does not prevent that.&lt;/p&gt;

&lt;p&gt;The attacker already has the original secret.&lt;/p&gt;

&lt;p&gt;Hashing protects against a different threat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ATTACKER
   |
   | database read access
   v
token_hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker sees the verifier, not the original token.&lt;/p&gt;

&lt;p&gt;Refresh-token rotation addresses another problem: reuse of a token after it has already been consumed.&lt;/p&gt;

&lt;p&gt;A simplified rotation flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Old refresh token
       |
       v
validate
       |
       v
revoke old token
       |
       v
generate new refresh token
       |
       v
hash new token
       |
       v
store new hash
       |
       v
return new token pair
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public demo for the starter follows this model: the raw refresh token is returned to the client, only its SHA-256 hash is stored, and refresh operations rotate the token. &lt;/p&gt;

&lt;p&gt;OAuth 2.0 Security Best Current Practice also describes refresh-token rotation as a mechanism for detecting refresh-token replay: a previously invalidated token being presented again can indicate that the token was compromised.&lt;/p&gt;

&lt;p&gt;So think of the protections as separate layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Random token generation
        +
SHA-256 storage
        +
Expiration
        +
Revocation
        +
Refresh-token rotation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each addresses a different part of the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. What Happens If the Database Is Leaked?
&lt;/h2&gt;

&lt;p&gt;This is where the design becomes interesting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plaintext storage
&lt;/h3&gt;

&lt;p&gt;Suppose the database contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refresh_tokens

id | user_id | token
----------------------------------------
1  | 42      | abc123...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker dumps the database.&lt;/p&gt;

&lt;p&gt;They now have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;abc123...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the token is still valid, they may be able to present it directly to the refresh endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hashed storage
&lt;/h3&gt;

&lt;p&gt;Now suppose the database contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refresh_tokens

id | user_id | token_hash
----------------------------------------
1  | 42      | 9f86d081...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker cannot simply send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9f86d081...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as the refresh token.&lt;/p&gt;

&lt;p&gt;That's only the SHA-256 representation.&lt;/p&gt;

&lt;p&gt;They would need to find an input whose SHA-256 digest matches the stored value.&lt;/p&gt;

&lt;p&gt;With a properly generated high-entropy refresh token, that is a fundamentally different problem from guessing a human password.&lt;/p&gt;

&lt;p&gt;This is why &lt;strong&gt;token randomness and token hashing must be considered together&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hashing a weak token does not magically make it strong.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"password123"
       |
       v
SHA-256
       |
       v
hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does not make the original secret cryptographically strong.&lt;/p&gt;

&lt;p&gt;An attacker can simply hash common guesses offline.&lt;/p&gt;

&lt;p&gt;The correct design is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cryptographically random token
             +
        SHA-256 hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  13. Why Not Encrypt the Refresh Token?
&lt;/h2&gt;

&lt;p&gt;Encryption is another possible approach.&lt;/p&gt;

&lt;p&gt;You could store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;encrypted(refreshToken)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SHA-256(refreshToken)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But encryption gives you something you don't actually need: the ability to recover the original token.&lt;/p&gt;

&lt;p&gt;For validation, the server only needs to answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Does this presented token match a valid stored token?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-way verifier is sufficient.&lt;/p&gt;

&lt;p&gt;Encryption also introduces another secret-management problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database
   +
encryption key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the application needs the encryption key to decrypt the stored tokens, compromise of both the database and the key may expose the original credentials.&lt;/p&gt;

&lt;p&gt;With one-way hashing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database
   |
   v
token hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;there is no decryption operation.&lt;/p&gt;

&lt;p&gt;This follows the principle of storing the minimum information necessary to perform the operation.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Don't Use the Same Storage Strategy for Passwords
&lt;/h2&gt;

&lt;p&gt;One of the easiest mistakes is to create a generic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;HashingService&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and use SHA-256 for everything.&lt;/p&gt;

&lt;p&gt;Don't.&lt;/p&gt;

&lt;p&gt;Passwords and refresh tokens have different threat models.&lt;/p&gt;

&lt;p&gt;For passwords:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Password
   |
   v
Argon2id / BCrypt / PBKDF2
   |
   v
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For refresh tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CSPRNG-generated token
   |
   v
SHA-256
   |
   v
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction matters because a password can be guessed from a relatively small search space.&lt;/p&gt;

&lt;p&gt;A properly generated 256-bit random token has an enormously larger search space.&lt;/p&gt;

&lt;p&gt;The hash algorithm is therefore only one part of the security equation.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. A Small Spring Boot Service
&lt;/h2&gt;

&lt;p&gt;A practical implementation can hide the details behind a service.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;RefreshTokenService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefreshTokenRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generateToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setTokenHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRevoked&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;generateToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SecureRandom&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;nextBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlEncoder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withoutPadding&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encodeToString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;MessageDigest&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                    &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HexFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;formatHex&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoSuchAlgorithmException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real application, the &lt;code&gt;SecureRandom&lt;/code&gt; instance should normally be reused rather than instantiated for every token generation, and the hashing logic is better extracted into its own component.&lt;/p&gt;

&lt;p&gt;The important architecture is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Controller
    |
    v
Authentication Service
    |
    v
Refresh Token Service
    |
    +---- generate raw token
    |
    +---- hash token
    |
    +---- persist hash
    |
    +---- return raw token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller never needs to know how the token is generated or stored.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. The Database Should Store State, Not Secrets
&lt;/h2&gt;

&lt;p&gt;A refresh-token table might contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refresh_tokens

id
user_id
token_hash
expires_at
revoked
created_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;p&gt;The database needs to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which user owns the token&lt;/li&gt;
&lt;li&gt;whether it has expired&lt;/li&gt;
&lt;li&gt;whether it has been revoked&lt;/li&gt;
&lt;li&gt;when it was created&lt;/li&gt;
&lt;li&gt;which hash corresponds to the token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not need the original bearer credential.&lt;/p&gt;

&lt;p&gt;This is a useful general security principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If your database only needs to verify a secret, don't automatically store the secret itself.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  17. One Important Caveat: Rotation Needs Concurrency Protection
&lt;/h2&gt;

&lt;p&gt;There is one subtle problem worth calling out.&lt;/p&gt;

&lt;p&gt;Imagine two requests arrive at almost exactly the same time with the same refresh token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request A ────────&amp;gt; refresh endpoint
Request B ────────&amp;gt; refresh endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both requests validate the old token before either transaction revokes it, both may succeed.&lt;/p&gt;

&lt;p&gt;Hashing does not solve this.&lt;/p&gt;

&lt;p&gt;Rotation alone does not necessarily solve it either.&lt;/p&gt;

&lt;p&gt;A production-grade implementation may need stronger concurrency control, depending on the application's requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transactional boundaries&lt;/li&gt;
&lt;li&gt;row-level locking&lt;/li&gt;
&lt;li&gt;optimistic locking&lt;/li&gt;
&lt;li&gt;atomic state transitions&lt;/li&gt;
&lt;li&gt;refresh-token families&lt;/li&gt;
&lt;li&gt;replay detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is an important distinction because "hashed refresh tokens" does not mean "complete refresh-token security."&lt;/p&gt;

&lt;p&gt;It solves one specific problem extremely well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;protecting the stored representation of the token if the database is exposed.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  18. The Security Model
&lt;/h2&gt;

&lt;p&gt;The final design can be summarized like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  LOGIN
                    |
                    v
          Generate random token
                    |
                    v
             Raw refresh token
                /         \
               /           \
              v             v
         Client          SHA-256
                           |
                           v
                       Database
                       token_hash


                 REFRESH
                    |
                    v
             Client sends raw token
                    |
                    v
                 SHA-256
                    |
                    v
             Database lookup
                    |
          +---------+---------+
          |                   |
       invalid              valid
          |                   |
          v                   v
        reject          revoke old token
                              |
                              v
                       generate new token
                              |
                              v
                         store hash
                              |
                              v
                      return new pair
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer has a specific responsibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cryptographic randomness
&lt;/h3&gt;

&lt;p&gt;Prevents attackers from predicting tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  SHA-256
&lt;/h3&gt;

&lt;p&gt;Prevents the database from containing reusable plaintext refresh credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expiration
&lt;/h3&gt;

&lt;p&gt;Limits the lifetime of a token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Revocation
&lt;/h3&gt;

&lt;p&gt;Allows the server to invalidate a token before its natural expiration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rotation
&lt;/h3&gt;

&lt;p&gt;Limits token reuse and provides a mechanism for detecting replay.&lt;/p&gt;

&lt;p&gt;None of these replaces the others.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. The Practical Rule
&lt;/h2&gt;

&lt;p&gt;If you remember only one thing from this article, make it this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't choose a hashing algorithm based on the fact that the value is called a "secret." Choose it based on the properties of the secret.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For passwords:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;human-generated
potentially guessable
        ↓
slow password hashing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For refresh tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server-generated
cryptographically random
high entropy
        ↓
SHA-256 verifier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And most importantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Random token
      +
hashed database representation
      +
expiration
      +
revocation
      +
rotation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is much stronger than simply generating a random token and putting the raw value into PostgreSQL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;JWT authentication is often presented as a token-generation problem.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;The difficult part is designing what happens around the token.&lt;/p&gt;

&lt;p&gt;Refresh tokens are a good example.&lt;/p&gt;

&lt;p&gt;They are long-lived bearer credentials, so storing their plaintext values in a database creates an unnecessary risk. The server doesn't need to recover the original token. It only needs to verify that the presented token corresponds to a valid server-side record.&lt;/p&gt;

&lt;p&gt;That makes a one-way hash a natural fit.&lt;/p&gt;

&lt;p&gt;The important distinction is that refresh tokens should be &lt;strong&gt;random enough that guessing is already impractical&lt;/strong&gt;. SHA-256 then gives the database a verifier without requiring the database to store the original bearer secret.&lt;/p&gt;

&lt;p&gt;And that is why the following design makes sense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;32-byte cryptographically random token
                    ↓
                 SHA-256
                    ↓
             PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While passwords should follow a different path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;human password
       ↓
Argon2id / BCrypt / PBKDF2
       ↓
   PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security isn't about using the strongest-looking algorithm everywhere.&lt;/p&gt;

&lt;p&gt;It's about matching the mechanism to the threat model.&lt;/p&gt;




&lt;h3&gt;
  
  
  AI disclosure
&lt;/h3&gt;

&lt;p&gt;This article was created with the assistance of AI and reviewed for technical accuracy by the author. The implementation details and security decisions should be verified against the requirements of the application before production use.&lt;/p&gt;

&lt;h1&gt;
  
  
  ABotWroteThis
&lt;/h1&gt;

</description>
      <category>springboot</category>
      <category>security</category>
      <category>jwt</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Production-Ready JWT Authentication with Spring Boot 4</title>
      <dc:creator>TeKa IT</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:58:58 +0000</pubDate>
      <link>https://dev.to/tekait/production-ready-jwt-authentication-with-spring-boot-4-3179</link>
      <guid>https://dev.to/tekait/production-ready-jwt-authentication-with-spring-boot-4-3179</guid>
      <description>&lt;h2&gt;
  
  
  Access tokens, hashed refresh tokens, rotation, PostgreSQL, Flyway, and Testcontainers
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Most JWT tutorials show how to generate a token.&lt;br&gt;&lt;br&gt;
This guide explains how to design the authentication system around it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Authentication looks deceptively simple in a demo. A client submits an email address and password, the server creates a JWT, and the client uses that token to call protected endpoints.&lt;/p&gt;

&lt;p&gt;The difficult questions appear later.&lt;/p&gt;

&lt;p&gt;What happens when the access token expires? How does logout work in a stateless system? What if a refresh token is stolen? Should tokens be stored in PostgreSQL? How do multiple devices remain logged in independently? What should happen when an old refresh token is submitted twice? And how do we verify that the entire lifecycle works against a real database?&lt;/p&gt;

&lt;p&gt;This guide answers those questions by building a production-minded authentication architecture with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Java 21&lt;/li&gt;
&lt;li&gt;Spring Boot 4&lt;/li&gt;
&lt;li&gt;Spring Security&lt;/li&gt;
&lt;li&gt;JWT access tokens&lt;/li&gt;
&lt;li&gt;opaque refresh tokens&lt;/li&gt;
&lt;li&gt;SHA-256 refresh-token hashing&lt;/li&gt;
&lt;li&gt;refresh-token rotation&lt;/li&gt;
&lt;li&gt;BCrypt password hashing&lt;/li&gt;
&lt;li&gt;PostgreSQL&lt;/li&gt;
&lt;li&gt;Flyway&lt;/li&gt;
&lt;li&gt;Docker Compose&lt;/li&gt;
&lt;li&gt;Swagger/OpenAPI&lt;/li&gt;
&lt;li&gt;Testcontainers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code examples follow the architecture of the &lt;strong&gt;Spring Boot JWT Starter Kit&lt;/strong&gt; and use its actual package structure and service names.&lt;/p&gt;

&lt;p&gt;The public demo repository is available at:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://github.com/teka-it/spring-boot-jwt-starter-demo&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why Most JWT Tutorials Are Incomplete
&lt;/li&gt;
&lt;li&gt;Access Tokens and Refresh Tokens
&lt;/li&gt;
&lt;li&gt;Storing Refresh Tokens Securely
&lt;/li&gt;
&lt;li&gt;Refresh-Token Rotation
&lt;/li&gt;
&lt;li&gt;Where Tokens Should Live on the Client
&lt;/li&gt;
&lt;li&gt;Stateless Security with Spring Security
&lt;/li&gt;
&lt;li&gt;Registration and Login
&lt;/li&gt;
&lt;li&gt;Implementing the Refresh and Logout Flows
&lt;/li&gt;
&lt;li&gt;Common JWT Mistakes
&lt;/li&gt;
&lt;li&gt;Testing with PostgreSQL and Testcontainers
&lt;/li&gt;
&lt;li&gt;Production Hardening
&lt;/li&gt;
&lt;li&gt;The Complete Architecture
&lt;/li&gt;
&lt;li&gt;Appendix A — JWT Claims
&lt;/li&gt;
&lt;li&gt;Appendix B — Authentication Status Codes
&lt;/li&gt;
&lt;li&gt;Appendix C — Authentication Timeline
&lt;/li&gt;
&lt;li&gt;Conclusion
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part I — Foundations
&lt;/h2&gt;

&lt;h2&gt;
  
  
  1. Why Most JWT Tutorials Are Incomplete
&lt;/h2&gt;

&lt;p&gt;Every week, new tutorials explain how to add JWT authentication to a Spring Boot application.&lt;/p&gt;

&lt;p&gt;Most follow roughly the same path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept a username and password.&lt;/li&gt;
&lt;li&gt;Load the user from a database.&lt;/li&gt;
&lt;li&gt;Generate a JWT.&lt;/li&gt;
&lt;li&gt;Add a filter.&lt;/li&gt;
&lt;li&gt;Protect an endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result works. A valid token reaches the API, Spring Security recognizes the caller, and protected resources become accessible.&lt;/p&gt;

&lt;p&gt;But generating a JWT is not the same as designing an authentication system.&lt;/p&gt;

&lt;p&gt;A real system must continue behaving correctly when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an access token expires;&lt;/li&gt;
&lt;li&gt;a user logs in on multiple devices;&lt;/li&gt;
&lt;li&gt;a refresh token is copied;&lt;/li&gt;
&lt;li&gt;a database backup is exposed;&lt;/li&gt;
&lt;li&gt;a user logs out;&lt;/li&gt;
&lt;li&gt;a password is reset;&lt;/li&gt;
&lt;li&gt;an account is disabled;&lt;/li&gt;
&lt;li&gt;two refresh requests arrive at nearly the same time;&lt;/li&gt;
&lt;li&gt;a signing secret needs to be rotated;&lt;/li&gt;
&lt;li&gt;an attacker repeatedly submits old credentials.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A basic JWT tutorial usually stops before these questions become visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT Is a Token Format, Not a Session Strategy
&lt;/h2&gt;

&lt;p&gt;A JSON Web Token is a signed set of claims. It can carry a subject, issuer, expiration time, and roles. A server can validate those claims without loading session state from a database.&lt;/p&gt;

&lt;p&gt;That is useful, but it does not define:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how long a login should last;&lt;/li&gt;
&lt;li&gt;how credentials should be renewed;&lt;/li&gt;
&lt;li&gt;how sessions should be revoked;&lt;/li&gt;
&lt;li&gt;how logout should work;&lt;/li&gt;
&lt;li&gt;how compromised credentials should be contained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those responsibilities belong to the authentication architecture surrounding the JWT.&lt;/p&gt;

&lt;p&gt;A useful mental model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JWT
└── proves information about one request

Session architecture
├── decides how long login persists
├── decides how credentials are renewed
├── manages revocation
├── manages logout
└── limits damage after compromise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Server Sessions and Stateless Access Tokens
&lt;/h2&gt;

&lt;p&gt;Traditional server-side authentication stores session state on the server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxu1cm5fvqjhpx2z6sy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxu1cm5fvqjhpx2z6sy6.png" alt="Traditional server-side session lookup" width="800" height="123"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Traditional server-side session lookup&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The browser sends a session identifier. The server loads the corresponding session and determines who the user is.&lt;/p&gt;

&lt;p&gt;JWT access tokens invert that model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ulkjswgyqhb9y34clkj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ulkjswgyqhb9y34clkj.png" alt="Stateless JWT access-token validation" width="797" height="114"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stateless JWT access-token validation&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The token carries the information required to authenticate the request. The server validates it cryptographically and does not need to retrieve an access-token record.&lt;/p&gt;

&lt;p&gt;That stateless property improves scalability, but it introduces a trade-off: a valid access token is difficult to revoke immediately without adding state back into the request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why One Long-Lived Token Is Not Enough
&lt;/h2&gt;

&lt;p&gt;The simplest JWT design issues one token that remains valid for days or weeks.&lt;/p&gt;

&lt;p&gt;That design is pleasant during development because it avoids refresh logic. It is also dangerous.&lt;/p&gt;

&lt;p&gt;A bearer token works for whoever possesses it. If a thirty-day access token is copied, the attacker can use it for thirty days. The signature remains valid, and a stateless server has no automatic way to know that the token changed hands.&lt;/p&gt;

&lt;p&gt;The safer model separates two concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;short-lived authorization&lt;/strong&gt;, handled by access tokens;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;long-lived session continuity&lt;/strong&gt;, handled by refresh tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The access token may remain valid for fifteen minutes. The refresh token may keep the session alive for thirty days, but it is stateful, revocable, stored securely, and rotated after use.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design Decision — Accept bounded access-token risk&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The starter does not persist access tokens. A stolen access token therefore remains usable until it expires. The risk is bounded by a short lifetime, while the normal request path remains stateless and does not require a database lookup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A JWT is not a complete authentication system.&lt;/li&gt;
&lt;li&gt;Long-lived access tokens create large attack windows.&lt;/li&gt;
&lt;li&gt;Stateless access-token validation and stateful session management can coexist.&lt;/li&gt;
&lt;li&gt;Refresh tokens exist because access tokens should expire quickly.&lt;/li&gt;
&lt;li&gt;Security depends on the lifecycle around the token, not merely its signature.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2. Access Tokens and Refresh Tokens
&lt;/h2&gt;

&lt;p&gt;Access tokens and refresh tokens are often returned together, but they have different responsibilities, lifetimes, storage requirements, and failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access Tokens Authorize API Requests
&lt;/h2&gt;

&lt;p&gt;The client sends an access token with a protected request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/api/users/me&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:8080&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer eyJhbGciOiJIUzI1NiJ9...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API validates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the cryptographic signature;&lt;/li&gt;
&lt;li&gt;the issuer;&lt;/li&gt;
&lt;li&gt;the expiration time;&lt;/li&gt;
&lt;li&gt;other configured claims.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When validation succeeds, Spring Security establishes an authenticated &lt;code&gt;SecurityContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A typical access-token payload might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spring-boot-jwt-starter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1785402000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1785402900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The starter uses the email address as the subject and stores role names in a custom &lt;code&gt;roles&lt;/code&gt; claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh Tokens Maintain Sessions
&lt;/h2&gt;

&lt;p&gt;A refresh token is not sent with ordinary API requests. It is presented only to the refresh endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/api/auth/refresh&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refreshToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a-long-random-url-safe-value"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server hashes the supplied value, finds the matching session record, verifies that it has not expired or been revoked, revokes it, and issues a replacement pair.&lt;/p&gt;

&lt;p&gt;The refresh token therefore acts as a stateful session credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different Lifetimes
&lt;/h2&gt;

&lt;p&gt;The starter’s default configuration expresses the difference clearly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_ISSUER:spring-boot-jwt-starter}&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
    &lt;span class="na"&gt;access-token-ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_ACCESS_TOKEN_TTL:15m}&lt;/span&gt;
    &lt;span class="na"&gt;refresh-token-ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_REFRESH_TOKEN_TTL:30d}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Default lifetime&lt;/th&gt;
&lt;th&gt;Primary purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access token&lt;/td&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;Authorize protected API requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh token&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;td&gt;Continue an authenticated session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fifteen minutes is not a universal rule, and thirty days is not automatically appropriate for every product. Financial, healthcare, administrative, and consumer applications may choose different values. The important property is the asymmetry: access tokens are deliberately brief, while refresh tokens receive stronger lifecycle controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateless and Stateful Layers
&lt;/h2&gt;

&lt;p&gt;The architecture combines two models:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbdcgagafknf316e8e61.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbdcgagafknf316e8e61.png" alt="Short-lived authorization and stateful session continuity" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Short-lived authorization and stateful session continuity&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most application traffic uses only the access token and does not query the refresh-token table. Authentication-related endpoints use PostgreSQL to create, consume, rotate, and revoke sessions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Behind the Starter Kit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The public API returns a &lt;code&gt;TokenResponse&lt;/code&gt; containing an access token, refresh token, token type, and access-token lifetime. The access token is a signed JWT. The refresh token is an opaque random value with no claims for the client to inspect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Multiple Devices
&lt;/h2&gt;

&lt;p&gt;Each successful login creates a new refresh-token record.&lt;/p&gt;

&lt;p&gt;A user can therefore have separate sessions for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a personal laptop;&lt;/li&gt;
&lt;li&gt;a mobile phone;&lt;/li&gt;
&lt;li&gt;a work computer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current starter revokes a specific refresh token during logout. It does not yet expose a session-management screen or “log out everywhere” endpoint, but its one-record-per-token model provides a foundation for those features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Access tokens authorize requests.&lt;/li&gt;
&lt;li&gt;Refresh tokens preserve login continuity.&lt;/li&gt;
&lt;li&gt;Access tokens are stateless and short-lived.&lt;/li&gt;
&lt;li&gt;Refresh tokens are stateful and long-lived.&lt;/li&gt;
&lt;li&gt;Each login can create an independent device session.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. Storing Refresh Tokens Securely
&lt;/h2&gt;

&lt;p&gt;A refresh token can generate new access tokens. That makes it a high-value credential.&lt;/p&gt;

&lt;p&gt;Storing it in plaintext would turn a database read breach into immediate session compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plaintext Problem
&lt;/h2&gt;

&lt;p&gt;Consider this table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;refresh_token&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema itself does not reveal whether &lt;code&gt;token&lt;/code&gt; contains plaintext or a hash. If the raw value is stored, anyone who can read the table can submit it to &lt;code&gt;/api/auth/refresh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Encryption at rest helps protect disks and backups, but the application still needs the ability to decrypt the value. A one-way hash provides a stronger property: the server does not need to recover the original token at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate a High-Entropy Opaque Token
&lt;/h2&gt;

&lt;p&gt;The starter generates 32 random bytes using &lt;code&gt;SecureRandom&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SecureRandom&lt;/span&gt; &lt;span class="no"&gt;SECURE_RANDOM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SecureRandom&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
&lt;span class="no"&gt;SECURE_RANDOM&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nextBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlEncoder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withoutPadding&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encodeToString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-two bytes provide 256 bits of randomness before encoding. URL-safe Base64 makes the result convenient to transport in JSON, headers, or cookies.&lt;/p&gt;

&lt;p&gt;The token is opaque. It contains no user ID, timestamp, role, or other claims. The client only needs to preserve and return it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash Before Persistence
&lt;/h2&gt;

&lt;p&gt;The raw value is hashed with SHA-256:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HexFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;formatHex&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoSuchAlgorithmException&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"SHA-256 is niet beschikbaar"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;exception&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stored entity receives the hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="n"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;OffsetDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwtProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refreshTokenTtl&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;refreshTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw token is returned to the client. Only the 64-character hexadecimal SHA-256 digest remains in PostgreSQL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl99q9sglc03rpgbyrjtc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl99q9sglc03rpgbyrjtc.png" alt="Generating and hashing an opaque refresh token" width="799" height="187"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Generating and hashing an opaque refresh token&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SHA-256 Instead of BCrypt?
&lt;/h2&gt;

&lt;p&gt;Passwords and refresh tokens are different kinds of secrets.&lt;/p&gt;

&lt;p&gt;Passwords are usually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chosen by humans;&lt;/li&gt;
&lt;li&gt;low entropy;&lt;/li&gt;
&lt;li&gt;reused;&lt;/li&gt;
&lt;li&gt;vulnerable to dictionary attacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BCrypt deliberately makes each guess expensive.&lt;/p&gt;

&lt;p&gt;Refresh tokens should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generated by a cryptographically secure random generator;&lt;/li&gt;
&lt;li&gt;high entropy;&lt;/li&gt;
&lt;li&gt;unique;&lt;/li&gt;
&lt;li&gt;infeasible to guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A slow password hash provides little additional protection against brute-forcing a genuinely random 256-bit token, while making every lookup more expensive.&lt;/p&gt;

&lt;p&gt;SHA-256 also enables a direct indexed lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;refreshTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BCrypt normally uses a random salt, so the same input does not produce a stable lookup value. Applications using BCrypt for tokens often have to load candidate records and call &lt;code&gt;matches&lt;/code&gt;, which is less efficient and more complex.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design Decision — Match the hash to the secret&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BCrypt is appropriate for human passwords. SHA-256 is appropriate here because the refresh token is already a high-entropy random credential and the application needs a deterministic lookup key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Database Shape
&lt;/h2&gt;

&lt;p&gt;The starter uses this migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;refresh_token&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;       &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;  &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;revoked&lt;/span&gt;     &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JPA entity constrains the token field to 64 characters, matching the hexadecimal SHA-256 output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The migration allows 255 characters while the entity declares 64. This is not functionally unsafe, but aligning both definitions to 64 would make the schema more precise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Hashing Does Not Solve
&lt;/h2&gt;

&lt;p&gt;Hashing protects refresh tokens at rest. It does not protect a raw token that is stolen from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;browser JavaScript storage;&lt;/li&gt;
&lt;li&gt;malware on the client device;&lt;/li&gt;
&lt;li&gt;application logs;&lt;/li&gt;
&lt;li&gt;network traffic without HTTPS;&lt;/li&gt;
&lt;li&gt;an insecure analytics or error-reporting integration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That risk is addressed by secure client storage, HTTPS, careful logging, and token rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never store reusable refresh tokens in plaintext.&lt;/li&gt;
&lt;li&gt;Generate tokens with a cryptographically secure random generator.&lt;/li&gt;
&lt;li&gt;Store only a deterministic cryptographic hash.&lt;/li&gt;
&lt;li&gt;BCrypt protects weak human passwords; SHA-256 fits high-entropy random tokens.&lt;/li&gt;
&lt;li&gt;Hashing at rest does not replace secure delivery and client storage.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. Refresh-Token Rotation
&lt;/h2&gt;

&lt;p&gt;A static refresh token remains useful until it expires or is revoked. If it is copied, the attacker and legitimate client can both use it.&lt;/p&gt;

&lt;p&gt;Rotation changes the token after every successful refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single-Use Credentials
&lt;/h2&gt;

&lt;p&gt;Assume a login produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access token:  A1
Refresh token: R1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client submits &lt;code&gt;R1&lt;/code&gt;, the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;validates it;&lt;/li&gt;
&lt;li&gt;marks its database record as revoked;&lt;/li&gt;
&lt;li&gt;creates &lt;code&gt;R2&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;stores the hash of &lt;code&gt;R2&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;creates &lt;code&gt;A2&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;returns &lt;code&gt;A2&lt;/code&gt; and &lt;code&gt;R2&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk98519kx1p3stoq8973t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk98519kx1p3stoq8973t.png" alt="Refresh-token rotation from R1 to R2" width="654" height="1100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Refresh-token rotation from R1 to R2&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A subsequent attempt to use &lt;code&gt;R1&lt;/code&gt; fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Starter Consumes a Token
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;RefreshTokenService&lt;/code&gt; performs the state transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refreshTokenRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;InvalidRefreshTokenException:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRevoked&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OffsetDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidRefreshTokenException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRevoked&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record is retained and marked as revoked rather than deleted.&lt;/p&gt;

&lt;p&gt;This has useful audit value: the database can distinguish a token that never existed from a token that existed and was consumed. The public error remains intentionally generic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation in One Transaction
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TokenRefreshService.refresh&lt;/code&gt; is transactional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawRefreshToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;RefreshToken&lt;/span&gt; &lt;span class="n"&gt;consumedToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;refreshTokenService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;consume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawRefreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;consumedToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authorities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRoles&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleGrantedAuthority&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"ROLE_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authentication&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nc"&gt;UsernamePasswordAuthenticationToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;authorities&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TokenResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;jwtTokenService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authentication&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;refreshTokenService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;jwtProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accessTokenTtl&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toSeconds&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old token is revoked and the new record is created within the transaction boundary. If persistence fails, the state changes roll back together.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common Pitfall — Partial rotation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Revoking the old token and creating the replacement in separate transactions can leave the session in an inconsistent state. The user may lose the session, or two usable tokens may coexist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Rotation Reduces the Attack Window
&lt;/h2&gt;

&lt;p&gt;Suppose an attacker steals &lt;code&gt;R1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the legitimate client refreshes first, &lt;code&gt;R1&lt;/code&gt; becomes revoked and the attacker’s later request fails.&lt;/p&gt;

&lt;p&gt;If the attacker refreshes first, the attacker receives &lt;code&gt;R2&lt;/code&gt;, while the legitimate client’s next use of &lt;code&gt;R1&lt;/code&gt; fails.&lt;/p&gt;

&lt;p&gt;Rotation therefore does not magically identify who is legitimate. It makes token reuse observable and limits the period in which the copied credential remains usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse Detection: Current Behavior and a Stronger Variant
&lt;/h2&gt;

&lt;p&gt;Version 1.0.0 rejects reuse because revoked records remain in the database and &lt;code&gt;consume&lt;/code&gt; checks &lt;code&gt;isRevoked()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It currently returns the same &lt;code&gt;InvalidRefreshTokenException&lt;/code&gt; for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an unknown token;&lt;/li&gt;
&lt;li&gt;an expired token;&lt;/li&gt;
&lt;li&gt;a revoked token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a good public response because it avoids leaking unnecessary information.&lt;/p&gt;

&lt;p&gt;A more advanced internal policy could distinguish reuse in security telemetry and respond by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;revoking every active refresh token for the user;&lt;/li&gt;
&lt;li&gt;requiring a new login;&lt;/li&gt;
&lt;li&gt;recording IP address and user agent;&lt;/li&gt;
&lt;li&gt;notifying the user;&lt;/li&gt;
&lt;li&gt;creating a security alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That stronger response requires retaining enough session-family information to identify related tokens. The current model does not yet store a family ID or replacement-token relationship.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Production Tip — Separate public errors from internal signals&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Return a generic 401 response to the client, but record whether the failure was caused by expiration, revocation, or reuse in structured security logs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Concurrency
&lt;/h2&gt;

&lt;p&gt;Two refresh requests carrying the same valid token could arrive concurrently.&lt;/p&gt;

&lt;p&gt;Both transactions might read the record before either commits the &lt;code&gt;revoked&lt;/code&gt; update. A robust high-concurrency system should make consumption atomic, for example through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pessimistic locking;&lt;/li&gt;
&lt;li&gt;an atomic conditional update;&lt;/li&gt;
&lt;li&gt;optimistic locking with a version column;&lt;/li&gt;
&lt;li&gt;a database constraint combined with session-family modeling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The starter’s implementation is suitable as a clear foundation, but applications expecting concurrent refresh traffic should explicitly test and harden this race.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat refresh tokens as single-use credentials.&lt;/li&gt;
&lt;li&gt;Revoke the old token before completing rotation.&lt;/li&gt;
&lt;li&gt;Keep rotation transactional.&lt;/li&gt;
&lt;li&gt;Reuse is a security signal, not merely a validation error.&lt;/li&gt;
&lt;li&gt;Concurrency control matters when multiple refreshes can race.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. Where Tokens Should Live on the Client
&lt;/h2&gt;

&lt;p&gt;The server can issue credentials securely and still lose the session through unsafe client storage.&lt;/p&gt;

&lt;p&gt;There is no browser storage option that removes every risk. The correct choice depends on architecture, threat model, and client type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Storage
&lt;/h2&gt;

&lt;p&gt;Local Storage is convenient:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accessToken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refreshToken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any JavaScript running in the page can also read those values. An XSS vulnerability may therefore exfiltrate the long-lived refresh token.&lt;/p&gt;

&lt;p&gt;Session Storage has a shorter persistence model, but the same JavaScript accessibility problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  HttpOnly Cookies
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;HttpOnly&lt;/code&gt; cookie cannot be read through normal page JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces direct token theft through XSS, although malicious JavaScript may still perform actions as the user while it is executing.&lt;/p&gt;

&lt;p&gt;Cookies are sent automatically by the browser, so the application must also consider CSRF. &lt;code&gt;SameSite&lt;/code&gt;, origin checks, narrowly scoped cookie paths, and CSRF tokens can be part of that defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Browser Pattern
&lt;/h2&gt;

&lt;p&gt;A common browser architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep the access token in memory;&lt;/li&gt;
&lt;li&gt;deliver the refresh token through an &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt; cookie;&lt;/li&gt;
&lt;li&gt;use a narrowly scoped refresh endpoint;&lt;/li&gt;
&lt;li&gt;issue a new in-memory access token after page reload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmamnjxnkm0hukr8s28h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmamnjxnkm0hukr8s28h.png" alt="A practical browser token-storage pattern" width="800" height="91"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A practical browser token-storage pattern&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This pattern is not implemented automatically by the starter. Version 1.0.0 accepts and returns the refresh token in JSON so that the backend remains client-agnostic. The README explicitly recommends considering an &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt; cookie for browser frontends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-Browser Clients
&lt;/h2&gt;

&lt;p&gt;Mobile, desktop, and machine clients have different storage mechanisms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mobile keychains or keystores;&lt;/li&gt;
&lt;li&gt;operating-system credential vaults;&lt;/li&gt;
&lt;li&gt;encrypted application storage;&lt;/li&gt;
&lt;li&gt;workload identity rather than refresh tokens for machine-to-machine communication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JWT design should not assume that every client is a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Browser storage is part of authentication security.&lt;/li&gt;
&lt;li&gt;Local Storage exposes tokens to JavaScript.&lt;/li&gt;
&lt;li&gt;HttpOnly cookies reduce direct token exfiltration but require CSRF consideration.&lt;/li&gt;
&lt;li&gt;Keeping access tokens in memory can reduce persistence.&lt;/li&gt;
&lt;li&gt;The starter’s JSON transport is intentionally client-neutral.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part II — Implementation
&lt;/h2&gt;

&lt;h2&gt;
  
  
  6. Stateless Security with Spring Security
&lt;/h2&gt;

&lt;p&gt;The starter does not implement a custom JWT filter. It uses Spring Security’s OAuth2 resource-server support for bearer-token parsing and JWT validation.&lt;/p&gt;

&lt;p&gt;That is an important architectural choice: security-sensitive token handling is delegated to established framework components.&lt;/p&gt;

&lt;h2&gt;
  
  
  SecurityFilterChain
&lt;/h2&gt;

&lt;p&gt;The central configuration is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;SecurityFilterChain&lt;/span&gt; &lt;span class="nf"&gt;securityFilterChain&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpSecurity&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;csrf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;csrf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionManagement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="nc"&gt;SessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STATELESS&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authorizeHttpRequests&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="s"&gt;"/api/health"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/api/auth/register"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/api/auth/login"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/api/auth/refresh"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/api/auth/logout"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/v3/api-docs/**"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/swagger-ui.html"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"/swagger-ui/**"&lt;/span&gt;
                    &lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;anyRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;oauth2ResourceServer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resourceServer&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="n"&gt;resourceServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                            &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jwtAuthenticationConverter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                    &lt;span class="n"&gt;jwtAuthenticationConverter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                            &lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Stateless Session Management
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionManagement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;SessionCreationPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STATELESS&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Security will not use an HTTP session to preserve authentication between requests. Every protected request must carry its own bearer token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public and Protected Routes
&lt;/h2&gt;

&lt;p&gt;Registration, login, refresh, logout, health, and API documentation are public. Every other route is authenticated.&lt;/p&gt;

&lt;p&gt;This default-deny shape is safer than individually remembering to protect each new controller.&lt;/p&gt;

&lt;p&gt;The fact that &lt;code&gt;/api/auth/logout&lt;/code&gt; is public at the filter-chain level does not mean it performs no authentication. It authenticates the session by validating the supplied refresh token. The endpoint does not require a still-valid access token, which allows a client to log out after access-token expiration.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT Encoder and Decoder
&lt;/h2&gt;

&lt;p&gt;The starter uses HS256 and requires a Base64-encoded secret of at least 32 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;SecretKey&lt;/span&gt; &lt;span class="nf"&gt;jwtSecretKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JwtProperties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;keyBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDecoder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;decode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyBytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"JWT_SECRET moet minimaal 256 bits (32 bytes) bevatten"&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SecretKeySpec&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyBytes&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"HmacSHA256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The encoder and decoder use Spring Security’s Nimbus integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;JwtEncoder&lt;/span&gt; &lt;span class="nf"&gt;jwtEncoder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SecretKey&lt;/span&gt; &lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;NimbusJwtEncoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withSecretKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MacAlgorithm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;JwtDecoder&lt;/span&gt; &lt;span class="nf"&gt;jwtDecoder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SecretKey&lt;/span&gt; &lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;JwtProperties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;NimbusJwtDecoder&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nc"&gt;NimbusJwtDecoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withSecretKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;macAlgorithm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MacAlgorithm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HS256&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setJwtValidator&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;JwtValidators&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createDefaultWithIssuer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createDefaultWithIssuer&lt;/code&gt; adds standard validation and checks the configured issuer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design Decision — Framework validation instead of a custom filter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A custom &lt;code&gt;OncePerRequestFilter&lt;/code&gt; can work, but it creates more security-sensitive code for the application to own. The starter relies on Spring Security’s resource-server machinery for token extraction, decoding, claim validation, authentication failures, and &lt;code&gt;SecurityContext&lt;/code&gt; integration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mapping Roles
&lt;/h2&gt;

&lt;p&gt;JWT roles are converted into Spring authorities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;JwtAuthenticationConverter&lt;/span&gt; &lt;span class="nf"&gt;jwtAuthenticationConverter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;JwtAuthenticationConverter&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JwtAuthenticationConverter&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setJwtGrantedAuthoritiesConverter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsStringList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GrantedAuthority&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SimpleGrantedAuthority&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                &lt;span class="s"&gt;"ROLE_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;
                        &lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JWT contains &lt;code&gt;USER&lt;/code&gt;; Spring Security receives &lt;code&gt;ROLE_USER&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating Access Tokens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;JwtTokenService&lt;/code&gt; keeps access-token creation focused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;createAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;authentication&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;issuedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;issuedAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;plus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accessTokenTtl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;authentication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthorities&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;GrantedAuthority:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getAuthority&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                            &lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceFirst&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^ROLE_"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;JwtClaimsSet&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JwtClaimsSet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;issuedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issuedAt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authentication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;claim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwtEncoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;JwtEncoderParameters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;getTokenValue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service does not know about HTTP, refresh tokens, repositories, or password verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use stateless session management for bearer-token APIs.&lt;/li&gt;
&lt;li&gt;Prefer framework-supported JWT validation over unnecessary custom filters.&lt;/li&gt;
&lt;li&gt;Validate the issuer as well as signature and time-based claims.&lt;/li&gt;
&lt;li&gt;Convert JWT claims into Spring authorities in one clear place.&lt;/li&gt;
&lt;li&gt;Keep token creation separate from login and refresh orchestration.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  7. Registration and Login
&lt;/h2&gt;

&lt;p&gt;A token should only be issued after Spring Security has verified the credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration
&lt;/h2&gt;

&lt;p&gt;The starter’s registration flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalizes the email address;&lt;/li&gt;
&lt;li&gt;validates the request;&lt;/li&gt;
&lt;li&gt;rejects duplicate email addresses;&lt;/li&gt;
&lt;li&gt;hashes the password with BCrypt;&lt;/li&gt;
&lt;li&gt;assigns the &lt;code&gt;USER&lt;/code&gt; role;&lt;/li&gt;
&lt;li&gt;persists the user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The BCrypt encoder is configured as a bean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;PasswordEncoder&lt;/span&gt; &lt;span class="nf"&gt;passwordEncoder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BCryptPasswordEncoder&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applications should never compare plaintext passwords manually or store them in reversible form.&lt;/p&gt;

&lt;h2&gt;
  
  
  DatabaseUserDetailsService
&lt;/h2&gt;

&lt;p&gt;Spring Security loads users through the database-backed service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;UserDetails&lt;/span&gt; &lt;span class="nf"&gt;loadUserByUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;UsernameNotFoundException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByEmailIgnoreCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UsernameNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="s"&gt;"Gebruiker niet gevonden"&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authorities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRoles&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleGrantedAuthority&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"ROLE_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;springframework&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;core&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userdetails&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPassword&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;authorities&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adapter translates the application’s &lt;code&gt;User&lt;/code&gt; entity into Spring Security’s &lt;code&gt;UserDetails&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  LoginService
&lt;/h2&gt;

&lt;p&gt;The real login orchestration is concise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LoginRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ROOT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authentication&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;authenticationManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authenticate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;UsernamePasswordAuthenticationToken&lt;/span&gt;
                            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unauthenticated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                            &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByEmailIgnoreCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="s"&gt;"Ingelogde gebruiker bestaat niet"&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TokenResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;jwtTokenService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authentication&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;refreshTokenService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;jwtProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accessTokenTtl&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toSeconds&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence is deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;normalize the email;&lt;/li&gt;
&lt;li&gt;delegate credential verification to &lt;code&gt;AuthenticationManager&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;load the domain entity needed to create the refresh-token relation;&lt;/li&gt;
&lt;li&gt;create the access token;&lt;/li&gt;
&lt;li&gt;create and persist the refresh-token hash;&lt;/li&gt;
&lt;li&gt;return both credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Load the User Again?
&lt;/h2&gt;

&lt;p&gt;The authenticated principal contains the email, password hash, enabled state, and authorities, but &lt;code&gt;RefreshToken&lt;/code&gt; has a JPA relationship to the application’s &lt;code&gt;User&lt;/code&gt; entity.&lt;/p&gt;

&lt;p&gt;The service therefore loads the entity after authentication.&lt;/p&gt;

&lt;p&gt;A future optimization could use a custom principal carrying the user ID or domain reference, but the current implementation is explicit and easy to understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  AuthController
&lt;/h2&gt;

&lt;p&gt;The HTTP layer delegates instead of implementing business rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/login"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;LoginRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;loginService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;login&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful response resembles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJhbGciOiJIUzI1NiJ9..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refreshToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mh8dJ2...url-safe-random-value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiresIn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Avoid Sensitive Logging
&lt;/h2&gt;

&lt;p&gt;Login requests contain passwords, and token responses contain bearer credentials.&lt;/p&gt;

&lt;p&gt;Do not log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request bodies for authentication endpoints;&lt;/li&gt;
&lt;li&gt;Authorization headers;&lt;/li&gt;
&lt;li&gt;raw refresh tokens;&lt;/li&gt;
&lt;li&gt;JWTs in exception messages;&lt;/li&gt;
&lt;li&gt;complete cookies carrying session credentials.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Structured logs should contain non-secret identifiers and outcomes, not reusable credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Authenticate before generating tokens.&lt;/li&gt;
&lt;li&gt;Let &lt;code&gt;AuthenticationManager&lt;/code&gt; and &lt;code&gt;PasswordEncoder&lt;/code&gt; verify passwords.&lt;/li&gt;
&lt;li&gt;Normalize identity fields consistently.&lt;/li&gt;
&lt;li&gt;Keep controllers thin.&lt;/li&gt;
&lt;li&gt;Never place passwords or raw tokens in logs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  8. Implementing Refresh and Logout
&lt;/h2&gt;

&lt;p&gt;The refresh endpoint authenticates an existing session rather than a password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controller Endpoints
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/refresh"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokenRefreshService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/logout"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tokenRefreshService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;noContent&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Complete Refresh Flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsv3ohz1e7d0zxjep1itm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsv3ohz1e7d0zxjep1itm.png" alt="The complete refresh flow" width="800" height="1766"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The complete refresh flow&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The service reconstructs an &lt;code&gt;Authentication&lt;/code&gt; from the user associated with the consumed refresh token. This lets &lt;code&gt;JwtTokenService&lt;/code&gt; use the same access-token creation method for login and refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logout
&lt;/h2&gt;

&lt;p&gt;Logout calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;revoke&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;refreshTokenRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawToken&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ifPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRevoked&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operation is idempotent from the caller’s perspective. An unknown token does not reveal whether a session existed.&lt;/p&gt;

&lt;p&gt;The endpoint returns &lt;code&gt;204 No Content&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Logout Can and Cannot Do
&lt;/h2&gt;

&lt;p&gt;Logout revokes the refresh token, preventing future access-token renewal.&lt;/p&gt;

&lt;p&gt;It does not invalidate an access token that has already been issued. That token may remain usable until its short expiration time.&lt;/p&gt;

&lt;p&gt;This is a normal consequence of stateless access tokens.&lt;/p&gt;

&lt;p&gt;Applications requiring immediate access-token revocation must introduce additional state, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a denylist;&lt;/li&gt;
&lt;li&gt;a user-level security version;&lt;/li&gt;
&lt;li&gt;introspection;&lt;/li&gt;
&lt;li&gt;very short lifetimes combined with gateway controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those options add cost and complexity and should be selected deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exception Handling
&lt;/h2&gt;

&lt;p&gt;Invalid refresh tokens become a stable 401 response through &lt;code&gt;GlobalExceptionHandler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidRefreshTokenException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ApiError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleInvalidRefreshToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;InvalidRefreshTokenException&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestURI&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation failures become 400 responses, and duplicate registration becomes 409 Conflict.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common Pitfall — Returning 500 for expected authentication failures&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Expired, malformed, revoked, and missing credentials are expected client-facing failures. They should not be reported as unexpected server errors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Refresh authenticates a session, not a password.&lt;/li&gt;
&lt;li&gt;Consume and replace the refresh token transactionally.&lt;/li&gt;
&lt;li&gt;Logout revokes renewal capability, not already-issued access tokens.&lt;/li&gt;
&lt;li&gt;Keep authentication failures generic to clients.&lt;/li&gt;
&lt;li&gt;Use correct HTTP status codes and stable error bodies.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part III — Reliability and Production
&lt;/h2&gt;

&lt;h2&gt;
  
  
  9. Common JWT Mistakes
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Mistake 1 — Long-Lived Access Tokens
&lt;/h2&gt;

&lt;p&gt;A long-lived access token avoids refresh logic but magnifies the effect of theft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; short-lived access tokens plus managed refresh sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2 — Persisting Access Tokens
&lt;/h2&gt;

&lt;p&gt;Looking up every JWT in the database recreates server-side sessions in a less direct form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; validate access tokens statelessly and persist only refresh-session state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3 — Plaintext Refresh Tokens
&lt;/h2&gt;

&lt;p&gt;A database reader can immediately impersonate active sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; store SHA-256 hashes of high-entropy opaque tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 4 — Weak Randomness
&lt;/h2&gt;

&lt;p&gt;Timestamps, UUID variants used without analysis, counters, or &lt;code&gt;java.util.Random&lt;/code&gt; should not generate bearer credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; &lt;code&gt;SecureRandom&lt;/code&gt; with sufficient bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 5 — BCrypt for Deterministic Token Lookup
&lt;/h2&gt;

&lt;p&gt;BCrypt is intentionally slow and salted. That fits passwords, not direct lookup of high-entropy tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; SHA-256 for the random refresh tokens used by this architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 6 — Static Refresh Tokens
&lt;/h2&gt;

&lt;p&gt;A copied token remains valid for its full lifetime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; rotate after every successful use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 7 — Validating Only the Signature
&lt;/h2&gt;

&lt;p&gt;A valid signature does not make an expired token acceptable, and a token from the wrong issuer should not be trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; validate time-based claims and issuer; add audience validation when the token is intended for a specific API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 8 — Oversized JWT Payloads
&lt;/h2&gt;

&lt;p&gt;JWT payloads are encoded, not encrypted. Clients and intermediaries can read claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; include only stable authorization data and non-sensitive identifiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 9 — Hardcoded Signing Secrets
&lt;/h2&gt;

&lt;p&gt;Secrets committed to Git may survive in history even after deletion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; inject secrets through environment configuration or a secrets manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 10 — Logging Tokens
&lt;/h2&gt;

&lt;p&gt;Bearer credentials in logs may be copied into search systems, support tools, and backups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; redact Authorization headers, cookies, passwords, and token fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 11 — Ignoring Refresh Races
&lt;/h2&gt;

&lt;p&gt;Two concurrent requests may consume the same token without atomic database controls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; use locking or an atomic update when the workload requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 12 — Treating CORS as Authentication
&lt;/h2&gt;

&lt;p&gt;CORS is a browser policy. It does not stop non-browser clients from calling the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; authenticate and authorize every protected server request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 13 — Assuming Logout Revokes JWTs
&lt;/h2&gt;

&lt;p&gt;Deleting or revoking the refresh token does not make a signed access token disappear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; communicate this behavior clearly and keep the access-token lifetime short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 14 — Returning Detailed Security Errors
&lt;/h2&gt;

&lt;p&gt;Telling an attacker exactly whether a user exists, a token expired, or a token was revoked may help enumeration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better:&lt;/strong&gt; return generic public messages and preserve detailed reasons in internal telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Convenience shortcuts often enlarge the attack surface.&lt;/li&gt;
&lt;li&gt;Keep access tokens small, short-lived, and stateless.&lt;/li&gt;
&lt;li&gt;Treat refresh tokens as managed, single-use session credentials.&lt;/li&gt;
&lt;li&gt;Never expose secrets through source control or logs.&lt;/li&gt;
&lt;li&gt;Test failure paths, not only successful login.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  10. Testing with PostgreSQL and Testcontainers
&lt;/h2&gt;

&lt;p&gt;Authentication code is easy to test incompletely.&lt;/p&gt;

&lt;p&gt;A mocked unit test may prove that one method invokes another. It does not prove that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flyway creates the expected schema;&lt;/li&gt;
&lt;li&gt;JPA mappings match PostgreSQL;&lt;/li&gt;
&lt;li&gt;password authentication works through Spring Security;&lt;/li&gt;
&lt;li&gt;bearer-token validation reaches protected endpoints;&lt;/li&gt;
&lt;li&gt;token rotation is persisted;&lt;/li&gt;
&lt;li&gt;reused tokens are rejected;&lt;/li&gt;
&lt;li&gt;logout prevents later refresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The starter therefore includes an end-to-end integration test against a real temporary PostgreSQL instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testcontainer Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Testcontainers&lt;/span&gt;
&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WebEnvironment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RANDOM_PORT&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationFlowIntegrationTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Container&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDatabaseName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jwt_starter_test"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jwt_test"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withPassword&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jwt_test"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic properties connect Spring Boot to the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DynamicPropertySource&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;DynamicPropertyRegistry&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"spring.datasource.url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getJdbcUrl&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"spring.datasource.username"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getUsername&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"spring.datasource.password"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getPassword&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"app.jwt.secret"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;JWT_SECRET&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"app.jwt.access-token-ttl"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"15m"&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"app.jwt.refresh-token-ttl"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"30d"&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Complete Lifecycle Test
&lt;/h2&gt;

&lt;p&gt;The test performs the same actions as a real client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;register a unique user;&lt;/li&gt;
&lt;li&gt;log in;&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;/api/users/me&lt;/code&gt; with the bearer token;&lt;/li&gt;
&lt;li&gt;refresh the session;&lt;/li&gt;
&lt;li&gt;verify the old refresh token is rejected;&lt;/li&gt;
&lt;li&gt;log out using the new refresh token;&lt;/li&gt;
&lt;li&gt;verify the logged-out token is rejected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The central assertions include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;TokenPayload&lt;/span&gt; &lt;span class="n"&gt;refreshed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postForToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"/api/auth/refresh"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"refreshToken"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstRefreshToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;secondRefreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;refreshed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secondRefreshToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstRefreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reuse is rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/auth/refresh"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bodyValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"refreshToken"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;firstRefreshToken&lt;/span&gt;
        &lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expectStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isUnauthorized&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And logout is verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/auth/logout"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bodyValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"refreshToken"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;secondRefreshToken&lt;/span&gt;
        &lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expectStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNoContent&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Protected Endpoint Test
&lt;/h2&gt;

&lt;p&gt;A separate test verifies that unauthenticated access is rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;protectedEndpointRejectsMissingToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/users/me"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expectStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isUnauthorized&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Additional Tests Worth Adding
&lt;/h2&gt;

&lt;p&gt;The included test covers the primary lifecycle. A production application should expand it with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invalid password;&lt;/li&gt;
&lt;li&gt;unknown email address;&lt;/li&gt;
&lt;li&gt;duplicate registration;&lt;/li&gt;
&lt;li&gt;malformed JWT;&lt;/li&gt;
&lt;li&gt;expired access token;&lt;/li&gt;
&lt;li&gt;wrong issuer;&lt;/li&gt;
&lt;li&gt;modified JWT signature;&lt;/li&gt;
&lt;li&gt;expired refresh token;&lt;/li&gt;
&lt;li&gt;disabled account;&lt;/li&gt;
&lt;li&gt;role-based authorization;&lt;/li&gt;
&lt;li&gt;concurrent refresh attempts;&lt;/li&gt;
&lt;li&gt;invalid request payloads;&lt;/li&gt;
&lt;li&gt;database uniqueness and cleanup behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Production Tip — Test time explicitly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Token tests become easier and less flaky when services depend on an injectable &lt;code&gt;Clock&lt;/code&gt; instead of calling &lt;code&gt;Instant.now()&lt;/code&gt; or &lt;code&gt;OffsetDateTime.now()&lt;/code&gt; directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a Real Database Matters
&lt;/h2&gt;

&lt;p&gt;H2 and PostgreSQL do not behave identically. Migrations, timestamp handling, constraints, identity columns, and SQL dialect details can differ.&lt;/p&gt;

&lt;p&gt;Testcontainers gives the test suite the same database family used in production without requiring a permanently shared test database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Test the authentication lifecycle through HTTP.&lt;/li&gt;
&lt;li&gt;Run migrations against a real PostgreSQL container.&lt;/li&gt;
&lt;li&gt;Assert rejection after rotation and logout.&lt;/li&gt;
&lt;li&gt;Test malformed and expired credentials.&lt;/li&gt;
&lt;li&gt;Add concurrency tests before relying on single-use guarantees under load.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  11. Production Hardening
&lt;/h2&gt;

&lt;p&gt;The starter is a foundation, not a substitute for application-specific security design.&lt;/p&gt;

&lt;p&gt;The following controls belong around the core architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS Everywhere
&lt;/h2&gt;

&lt;p&gt;Bearer credentials must never travel over plaintext HTTP outside local development.&lt;/p&gt;

&lt;p&gt;Enforce TLS at the load balancer or ingress layer, redirect HTTP, and consider HSTS for browser deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secret Management
&lt;/h2&gt;

&lt;p&gt;The starter reads &lt;code&gt;JWT_SECRET&lt;/code&gt; from configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate a Base64-encoded value containing at least 32 random bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit production secrets to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Git;&lt;/li&gt;
&lt;li&gt;Docker images;&lt;/li&gt;
&lt;li&gt;sample configuration;&lt;/li&gt;
&lt;li&gt;CI logs;&lt;/li&gt;
&lt;li&gt;support tickets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a deployment platform’s secret store or a dedicated secrets manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symmetric vs Asymmetric Signing
&lt;/h2&gt;

&lt;p&gt;HS256 is simple and appropriate when the same trusted application boundary signs and validates tokens.&lt;/p&gt;

&lt;p&gt;As systems grow, asymmetric algorithms can provide cleaner separation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the authorization service holds the private key;&lt;/li&gt;
&lt;li&gt;APIs validate with public keys;&lt;/li&gt;
&lt;li&gt;validators cannot mint tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A public-key architecture also supports key identifiers and published JWK sets more naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Rotation
&lt;/h2&gt;

&lt;p&gt;A single static key creates operational risk.&lt;/p&gt;

&lt;p&gt;A mature deployment should support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;key identifiers (&lt;code&gt;kid&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;overlap between old and new validation keys;&lt;/li&gt;
&lt;li&gt;controlled token-signing rollover;&lt;/li&gt;
&lt;li&gt;emergency rotation procedures;&lt;/li&gt;
&lt;li&gt;audit trails for key access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Audience Validation
&lt;/h2&gt;

&lt;p&gt;The starter validates the issuer. Applications issuing tokens for a particular API should consider an &lt;code&gt;aud&lt;/code&gt; claim and corresponding audience validation.&lt;/p&gt;

&lt;p&gt;This prevents a token intended for one service from being accepted by another service that trusts the same issuer or key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate Limiting and Abuse Controls
&lt;/h2&gt;

&lt;p&gt;Protect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;registration;&lt;/li&gt;
&lt;li&gt;login;&lt;/li&gt;
&lt;li&gt;refresh;&lt;/li&gt;
&lt;li&gt;password-reset endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Controls may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;per-IP limits;&lt;/li&gt;
&lt;li&gt;per-account limits;&lt;/li&gt;
&lt;li&gt;exponential backoff;&lt;/li&gt;
&lt;li&gt;CAPTCHA after suspicious behavior;&lt;/li&gt;
&lt;li&gt;temporary lockouts designed to avoid denial-of-service abuse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  CORS
&lt;/h2&gt;

&lt;p&gt;Configure only the origins, methods, and headers required by trusted browser clients. Avoid broad wildcard settings when credentials or sensitive APIs are involved.&lt;/p&gt;

&lt;p&gt;Remember that CORS does not protect the API from scripts, mobile applications, or command-line clients outside a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookies and CSRF
&lt;/h2&gt;

&lt;p&gt;When refresh tokens are delivered in cookies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;set &lt;code&gt;HttpOnly&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;set &lt;code&gt;Secure&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;choose &lt;code&gt;SameSite&lt;/code&gt; intentionally;&lt;/li&gt;
&lt;li&gt;restrict &lt;code&gt;Path&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;consider CSRF tokens or origin verification;&lt;/li&gt;
&lt;li&gt;define cookie expiration consistently with server-side session expiration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Content Security Policy
&lt;/h2&gt;

&lt;p&gt;A strong CSP reduces the chance and impact of script injection. It complements HttpOnly cookies and secure frontend engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging and Monitoring
&lt;/h2&gt;

&lt;p&gt;Record security-relevant events without recording credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;login success and failure;&lt;/li&gt;
&lt;li&gt;refresh success;&lt;/li&gt;
&lt;li&gt;expired or revoked refresh attempts;&lt;/li&gt;
&lt;li&gt;logout;&lt;/li&gt;
&lt;li&gt;account disablement;&lt;/li&gt;
&lt;li&gt;role changes;&lt;/li&gt;
&lt;li&gt;suspicious request rates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful context may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;internal user ID;&lt;/li&gt;
&lt;li&gt;session ID;&lt;/li&gt;
&lt;li&gt;timestamp;&lt;/li&gt;
&lt;li&gt;IP address;&lt;/li&gt;
&lt;li&gt;user agent;&lt;/li&gt;
&lt;li&gt;request correlation ID;&lt;/li&gt;
&lt;li&gt;reason category.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Review privacy and retention requirements before storing device or network metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session Metadata
&lt;/h2&gt;

&lt;p&gt;The current refresh-token entity stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hash;&lt;/li&gt;
&lt;li&gt;user;&lt;/li&gt;
&lt;li&gt;expiration;&lt;/li&gt;
&lt;li&gt;revoked flag;&lt;/li&gt;
&lt;li&gt;creation time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Possible additions include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;session ID;&lt;/li&gt;
&lt;li&gt;token family ID;&lt;/li&gt;
&lt;li&gt;last-used time;&lt;/li&gt;
&lt;li&gt;device label;&lt;/li&gt;
&lt;li&gt;IP address;&lt;/li&gt;
&lt;li&gt;user agent;&lt;/li&gt;
&lt;li&gt;revocation reason;&lt;/li&gt;
&lt;li&gt;replaced-by token ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These fields enable session dashboards and stronger reuse response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup
&lt;/h2&gt;

&lt;p&gt;Expired and revoked rows accumulate over time.&lt;/p&gt;

&lt;p&gt;Add a scheduled cleanup process with a retention policy. You may retain revoked records briefly for investigation while deleting old data after the security and audit window closes.&lt;/p&gt;

&lt;p&gt;Add indexes that match actual queries and cleanup operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Account-State Changes
&lt;/h2&gt;

&lt;p&gt;Consider revoking sessions when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a password changes;&lt;/li&gt;
&lt;li&gt;multi-factor authentication settings change;&lt;/li&gt;
&lt;li&gt;an account is disabled;&lt;/li&gt;
&lt;li&gt;a role is removed;&lt;/li&gt;
&lt;li&gt;an administrator initiates a security reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because access tokens remain valid until expiration, the access-token lifetime defines the maximum delay unless you introduce a user security version or another stateful check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error Handling
&lt;/h2&gt;

&lt;p&gt;Return consistent, limited client errors. Avoid stack traces and database details.&lt;/p&gt;

&lt;p&gt;The starter’s &lt;code&gt;ApiError&lt;/code&gt; model provides a useful base for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;status;&lt;/li&gt;
&lt;li&gt;reason;&lt;/li&gt;
&lt;li&gt;message;&lt;/li&gt;
&lt;li&gt;request path;&lt;/li&gt;
&lt;li&gt;timestamp;&lt;/li&gt;
&lt;li&gt;field validation errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Time and Clock Skew
&lt;/h2&gt;

&lt;p&gt;JWT validation depends on reliable clocks.&lt;/p&gt;

&lt;p&gt;Use synchronized infrastructure time and define a small, intentional clock-skew policy. Excessive tolerance weakens expiration guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependency Maintenance
&lt;/h2&gt;

&lt;p&gt;Authentication depends on Spring Security, Nimbus JOSE/JWT through Spring, the PostgreSQL driver, Flyway, and other libraries.&lt;/p&gt;

&lt;p&gt;Keep dependencies current, monitor advisories, and run automated tests during upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Remember
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Production security is layered.&lt;/li&gt;
&lt;li&gt;Manage signing keys as critical secrets.&lt;/li&gt;
&lt;li&gt;Add audience validation when tokens target a specific API.&lt;/li&gt;
&lt;li&gt;Rate-limit authentication endpoints.&lt;/li&gt;
&lt;li&gt;Log security outcomes, never bearer credentials.&lt;/li&gt;
&lt;li&gt;Plan session cleanup, key rotation, and incident response before they are needed.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  12. The Complete Architecture
&lt;/h2&gt;

&lt;p&gt;The final system combines stateless authorization with stateful session continuity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86eg37fxbp1fx6u8czdt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86eg37fxbp1fx6u8czdt.png" alt="Complete Spring Boot JWT authentication architecture" width="799" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Complete Spring Boot JWT authentication architecture&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Complete Lifecycle
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Registration
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The client submits validated profile data.&lt;/li&gt;
&lt;li&gt;The server normalizes the email.&lt;/li&gt;
&lt;li&gt;BCrypt hashes the password.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;USER&lt;/code&gt; role is assigned.&lt;/li&gt;
&lt;li&gt;Flyway-managed PostgreSQL tables store the account.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Login
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The client submits email and password.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AuthenticationManager&lt;/code&gt; delegates to the configured authentication provider.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DatabaseUserDetailsService&lt;/code&gt; loads the account and roles.&lt;/li&gt;
&lt;li&gt;BCrypt verifies the password.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JwtTokenService&lt;/code&gt; issues a fifteen-minute access token.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RefreshTokenService&lt;/code&gt; generates 32 random bytes.&lt;/li&gt;
&lt;li&gt;The server stores only the SHA-256 hash.&lt;/li&gt;
&lt;li&gt;The client receives the token pair.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Protected Request
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The client sends &lt;code&gt;Authorization: Bearer &amp;lt;access token&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Spring Security’s resource server extracts the JWT.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JwtDecoder&lt;/code&gt; validates HS256, standard claims, and issuer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JwtAuthenticationConverter&lt;/code&gt; maps roles.&lt;/li&gt;
&lt;li&gt;Spring Security establishes the &lt;code&gt;SecurityContext&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The controller handles the authenticated request.&lt;/li&gt;
&lt;li&gt;No access-token database lookup occurs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Refresh
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The client submits the raw refresh token.&lt;/li&gt;
&lt;li&gt;The server hashes it.&lt;/li&gt;
&lt;li&gt;PostgreSQL returns the matching session record.&lt;/li&gt;
&lt;li&gt;The service verifies expiration and revocation.&lt;/li&gt;
&lt;li&gt;The old record is marked revoked.&lt;/li&gt;
&lt;li&gt;A new access JWT is issued.&lt;/li&gt;
&lt;li&gt;A new random refresh token is generated.&lt;/li&gt;
&lt;li&gt;Its SHA-256 hash is stored.&lt;/li&gt;
&lt;li&gt;The new pair is returned in the same transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Logout
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The client submits its current refresh token.&lt;/li&gt;
&lt;li&gt;The server hashes it.&lt;/li&gt;
&lt;li&gt;The matching record is marked revoked.&lt;/li&gt;
&lt;li&gt;The endpoint returns 204.&lt;/li&gt;
&lt;li&gt;The access token expires naturally.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/main/java/nl/javalaunch/starter
├── auth
│   ├── controller
│   ├── dto
│   └── service
├── common
│   └── exception
├── config
├── health
├── refreshtoken
│   ├── entity
│   ├── repository
│   └── service
├── role
│   ├── entity
│   └── repository
├── security
└── user
    ├── controller
    ├── dto
    ├── entity
    └── repository
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This feature-oriented organization keeps HTTP, orchestration, persistence, and security concerns discoverable without forcing every class into generic global &lt;code&gt;controller&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt;, and &lt;code&gt;repository&lt;/code&gt; folders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behind the Starter Kit
&lt;/h2&gt;

&lt;p&gt;The full starter includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;complete source code;&lt;/li&gt;
&lt;li&gt;PostgreSQL migrations;&lt;/li&gt;
&lt;li&gt;Docker Compose;&lt;/li&gt;
&lt;li&gt;OpenAPI documentation;&lt;/li&gt;
&lt;li&gt;sample API requests;&lt;/li&gt;
&lt;li&gt;integration tests;&lt;/li&gt;
&lt;li&gt;architecture and security notes;&lt;/li&gt;
&lt;li&gt;configuration and customization guides.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public demo intentionally exposes the architecture and usage examples without redistributing the commercial source code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix A — JWT Claims
&lt;/h2&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;iss&lt;/code&gt; — Issuer
&lt;/h2&gt;

&lt;p&gt;Identifies the authority that issued the token.&lt;/p&gt;

&lt;p&gt;The starter uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spring-boot-jwt-starter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decoder validates this value.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;sub&lt;/code&gt; — Subject
&lt;/h2&gt;

&lt;p&gt;Identifies the principal represented by the token.&lt;/p&gt;

&lt;p&gt;The starter uses the normalized email address.&lt;/p&gt;

&lt;p&gt;For systems where email addresses can change, an immutable user ID may be a stronger long-term subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;iat&lt;/code&gt; — Issued At
&lt;/h2&gt;

&lt;p&gt;Records when the token was created.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;exp&lt;/code&gt; — Expiration
&lt;/h2&gt;

&lt;p&gt;Defines the time after which the token must be rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;nbf&lt;/code&gt; — Not Before
&lt;/h2&gt;

&lt;p&gt;Optionally prevents acceptance before a specified time.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;aud&lt;/code&gt; — Audience
&lt;/h2&gt;

&lt;p&gt;Identifies the intended recipient or API.&lt;/p&gt;

&lt;p&gt;The current starter does not add or validate an audience claim. Add it when tokens should be restricted to a particular service.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;jti&lt;/code&gt; — JWT ID
&lt;/h2&gt;

&lt;p&gt;Provides a unique identifier for a token.&lt;/p&gt;

&lt;p&gt;The starter does not use &lt;code&gt;jti&lt;/code&gt; because access tokens are not persisted or individually revoked. It can be useful for diagnostics or denylist-based designs, though it should not be added without a clear purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;roles&lt;/code&gt; — Custom Claim
&lt;/h2&gt;

&lt;p&gt;Carries application roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The converter maps these to &lt;code&gt;ROLE_USER&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix B — Authentication Status Codes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Authentication use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;Successful login or refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;201 Created&lt;/td&gt;
&lt;td&gt;Successful registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;204 No Content&lt;/td&gt;
&lt;td&gt;Successful logout with no response body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 Bad Request&lt;/td&gt;
&lt;td&gt;Invalid request shape or validation failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401 Unauthorized&lt;/td&gt;
&lt;td&gt;Missing, invalid, expired, or revoked credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403 Forbidden&lt;/td&gt;
&lt;td&gt;Authenticated user lacks required authority&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;409 Conflict&lt;/td&gt;
&lt;td&gt;Registration conflicts with an existing email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 Too Many Requests&lt;/td&gt;
&lt;td&gt;Authentication endpoint rate limit exceeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 Internal Server Error&lt;/td&gt;
&lt;td&gt;Unexpected server or configuration failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The historical name “Unauthorized” is slightly misleading: 401 generally means the request is not successfully authenticated. A user who is authenticated but not permitted should receive 403.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix C — Authentication Timeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;08:00  User logs in
       ├── Access token A1 expires at 08:15
       └── Refresh token R1 expires in 30 days

08:10  Client calls protected endpoint with A1
       └── Spring Security validates JWT without token lookup

08:15  A1 expires

08:16  Client submits R1
       ├── Server hashes R1
       ├── R1 record is marked revoked
       ├── Access token A2 is created
       └── Refresh token R2 is created and hashed

08:17  Reuse of R1
       └── 401 Unauthorized

08:30  Client logs out with R2
       └── R2 record is marked revoked

08:31  Refresh with R2
       └── 401 Unauthorized

Until A2 expires
       └── A2 may still authorize requests because access JWTs are stateless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Secure JWT authentication is not about producing a signed string after login.&lt;/p&gt;

&lt;p&gt;It is about designing a lifecycle with explicit answers to difficult questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which credentials authorize ordinary requests?&lt;/li&gt;
&lt;li&gt;Which credentials preserve the session?&lt;/li&gt;
&lt;li&gt;How long does each credential remain valid?&lt;/li&gt;
&lt;li&gt;Which state is stored?&lt;/li&gt;
&lt;li&gt;How is stored state protected?&lt;/li&gt;
&lt;li&gt;What becomes invalid during refresh?&lt;/li&gt;
&lt;li&gt;What does logout revoke?&lt;/li&gt;
&lt;li&gt;How are suspicious retries detected?&lt;/li&gt;
&lt;li&gt;How is the behavior verified against real infrastructure?&lt;/li&gt;
&lt;li&gt;What happens when keys, accounts, roles, or production requirements change?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture in this guide makes a deliberate split.&lt;/p&gt;

&lt;p&gt;Access tokens are short-lived JWTs. They are validated statelessly through Spring Security’s resource-server support and are not stored in PostgreSQL.&lt;/p&gt;

&lt;p&gt;Refresh tokens are opaque, high-entropy session credentials. Their SHA-256 hashes are stored, their records can be revoked, and each successful refresh rotates the credential.&lt;/p&gt;

&lt;p&gt;Passwords are protected with BCrypt. Database structure is managed by Flyway. The full authentication lifecycle is tested through HTTP against PostgreSQL with Testcontainers.&lt;/p&gt;

&lt;p&gt;That combination preserves the scalability of stateless API authorization without pretending that long-lived sessions can be managed safely without state.&lt;/p&gt;

&lt;p&gt;The implementation is intentionally a foundation rather than the final word for every application. High-risk systems should extend it with audience validation, asymmetric signing, key rotation, atomic refresh consumption, richer session metadata, reuse response, rate limiting, monitoring, and application-specific security review.&lt;/p&gt;

&lt;p&gt;But the central principle remains stable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A production-ready authentication system is not defined by what happens when login succeeds. It is defined by how safely and predictably the system behaves after credentials expire, leak, rotate, fail, and are revoked.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Reference Implementation
&lt;/h2&gt;

&lt;p&gt;The public demo repository contains the architecture overview and usage examples:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://github.com/teka-it/spring-boot-jwt-starter-demo&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The complete reference implementation—including source code, PostgreSQL migrations, Docker Compose, OpenAPI documentation, and integration tests—is available as the Spring Boot JWT Starter Kit:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://tekait.gumroad.com/l/spring-boot-jwt-starter&lt;/code&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>jwt</category>
      <category>spring</category>
      <category>java</category>
    </item>
  </channel>
</rss>
