Spring · اسپرینگ متوسطIntermediate ~43 دقیقه مطالعه~36 min read
Spring Security: JWT، OAuth2 و مجوزدهیSpring Security: JWT, OAuth2 & Authorization
از صفر یاد میگیری Spring Security 6 چطور بهعنوان یک زنجیره فیلتر ساده کار میکند و احراز هویت، JWT بدونحالت، OAuth2/OIDC، امنیت سطح متد و تلههای واقعی CSRF/CORS را با تشبیههای ملموس و کد کامل تسلط پیدا میکنی.Learn from scratch how Spring Security 6 is really just an ordered filter chain, then master authentication, stateless JWT, OAuth2/OIDC, method security, and the real CSRF/CORS gotchas through concrete analogies and complete code.
پیشنیاز:Prerequisites: هستهٔ Spring: IoC، DI، Bean و AOPSpring Core: IoC, DI, Beans & AOP
خیلیها فکر میکنند Spring Security یک جعبهٔ سیاهِ جادویی است که «یکجوری» جلوی آدمها را میگیرد. حقیقت خیلی آرامبخشتر است: کل ماجرا یک صف از نگهبانهاست که پشت سر هم میایستند و هر کدام یک کار کوچک انجام میدهند. وقتی این تصویر را بگیری، بقیهٔ چیزها — JWT، OAuth2، CSRF — فقط جزئیاتِ همان صف میشوند. بیا از صفر بسازیمش.
اول یک مدل ذهنی میسازیم: Spring Security یعنی یک زنجیره از فیلترها. بعد تفاوت احراز هویت (authentication) و مجوزدهی (authorization) را جا میاندازیم. سپس سبک پیکربندی Spring Security 6 (بدون Adapter، با Lambda DSL) را یاد میگیریم، بعد UserDetailsService و BCrypt برای پسورد، بعد جریان بدونحالت JWT با توکنهای refresh و محل ذخیرهسازی، بعد سه نقش OAuth2/OIDC، بعد امنیت سطح متد، و در پایان دو مبحثی که همه اشتباه میکنند: CSRF و CORS. آخرش هم ۱۵ سؤال مصاحبه با پاسخ کامل.
بخش ۰ — واژههایی که باید بلد باشی
قبل از شروع، چند کلمه را با یک تشبیه کوتاه باز کنیم تا بعداً وسط راه گیر نکنیم:
- Servlet Filter (فیلتر سروِلتی): در جاوای وب، هر درخواست HTTP قبل از رسیدن به کد تو، از یک زنجیره از «فیلتر»ها عبور میکند. فیلتر مثل یک نگهبانِ دمِ در است که میتواند درخواست را ببیند، تغییر دهد، رد کند، یا بگذارد برود جلو. Spring Security چیزی جز چند فیلتر هوشمند نیست.
- Principal (پرینسیپال): یعنی «آن موجودی که الان دارد صحبت میکند» — معمولاً کاربر لاگینکرده. اسمش از منطق میآید ولی فقط یعنی «هویتِ فعلی».
- Authority / GrantedAuthority (اختیار): یک برچسبِ اجازه که به کاربر چسبیده، مثل
ROLE_ADMINیاSCOPE_read. مجموعهٔ این برچسبها تعیین میکند کاربر چه کارهایی میتواند بکند. - Token (توکن): یک تکه دادهٔ قابلحمل که میگوید «صاحب من احراز هویت شده». بهجای اینکه هر بار پسورد بفرستی، این کارت را نشان میدهی.
- Stateless (بدونحالت): یعنی سرور هیچ حافظهای از تو بین دو درخواست نگه نمیدارد. هر درخواست باید خودش کامل و خوداثباتکننده باشد.
حالا که اینها را داریم، برویم سراغ اصل ماجرا.
مدل ذهنی: این یک زنجیره فیلتر است، نه جادو
تصور کن وارد فرودگاه میشوی. یک راهرو هست با ایستگاههای پشتسرِهم: اول کنترل بلیت، بعد چک پاسپورت، بعد بازرسی بدنی، بعد گیتِ بوردینگ. هر ایستگاه یک کار کوچک میکند و تو را میفرستد ایستگاه بعد. اگر جایی رد شوی، همانجا متوقفت میکنند. Spring Security دقیقاً همین راهروست — یک زنجیره از ایستگاههای بازرسی که درخواست HTTP از تویشان عبور میکند.
Spring Security در هستهی خود یک Filter سروِلتی واحد است (DelegatingFilterProxy → FilterChainProxy) که Spring Boot آن را زودهنگام در لیست فیلترهای کانتینر ثبت میکند. هر درخواست HTTP از این پروکسی عبور میکند، که یک SecurityFilterChain را انتخاب میکند (اولین زنجیرهای که RequestMatcher آن مطابقت کند) و لیست مرتب فیلترهای داخلیاش را اجرا میکند. هیچچیز Spring Security در لایهی وب جنبهگرا (aspect-oriented) نیست — همهچیز فیلترهای سادهٔ سروِلتی است که به ترتیبی ثابت اجرا میشوند.
این ترتیب مهم است و مصاحبهکنندهها آن را میکاوند. یک برش نمونه و سادهشده از زنجیرهٔ پیشفرض:
DisableEncodeUrlFilter
SecurityContextHolderFilter // بارگذاری/ذخیرهٔ SecurityContext
CsrfFilter // اعتبارسنجی توکن CSRF برای متدهای تغییردهنده
LogoutFilter
UsernamePasswordAuthenticationFilter // پردازش فرم لاگین POST /login
...
BearerTokenAuthenticationFilter // منبع OAuth2: استخراج توکن Bearer
...
ExceptionTranslationFilter // گرفتن AuthenticationException / AccessDeniedException
AuthorizationFilter // آخرین فیلتر: اعمال قوانین authorizeHttpRequests
بیا این را خط به خط بفهمیم. اسمها ترسناکاند اما هر کدام یک کارِ ساده دارند: SecurityContextHolderFilter میآید اول تا اگر سشنی هست، هویتِ ذخیرهشده را دربیاورد. CsrfFilter توکن ضدِ جعل را چک میکند. UsernamePasswordAuthenticationFilter فرم لاگین را میگیرد. BearerTokenAuthenticationFilter توکن JWT را از هدر بیرون میکشد. و در ته صف، AuthorizationFilter تصمیم نهایی دسترسی را میگیرد.
نکتهٔ کلیدی که همهچیز را روشن میکند: فیلترهای احراز هویت قبل از AuthorizationFilter اجرا میشوند. وظیفهٔ یک فیلتر احراز هویت فقط پُر کردن SecurityContextHolder با یک Authentication است؛ دربارهٔ دسترسی تصمیم نمیگیرد. AuthorizationFilter نهایی آن Authentication را میخواند و قوانین شما را اعمال میکند.
نگهبانهای اول فقط هویتت را برمیدارند و روی یک برگه مینویسند («این آقا کاربر ali با نقش USER است»). آنها هیچوقت نمیگویند «برو تو» یا «نه». تنها نگهبانِ آخر، آن برگه را میخواند و تصمیم میگیرد. اگر برگه خالی باشد و مسیر محافظتشده باشد → رد.
اگر درخواستی بدون احراز هویت به AuthorizationFilter برسد و به یک قانون محافظتشده بخورد، یک AccessDeniedException پرتاب میشود که ExceptionTranslationFilter آن را میگیرد و یا احراز هویت را آغاز میکند (ریدایرکت به لاگین / ارسال ۴۰۱) یا ۴۰۳ برمیگرداند. تفاوت ۴۰۱ و ۴۰۳ را در ذهن نگه دار: ۴۰۱ یعنی «نمیدانم تو کیستی» (احرازنشده) و ۴۰۳ یعنی «میدانم کیستی، ولی اجازه نداری» (احرازشده اما ممنوع).
احراز هویت (authentication) در برابر مجوزدهی (authorization)
دمِ درِ کنسرت دو چیز جدا اتفاق میافتد. اول کارت شناساییات را چک میکنند تا مطمئن شوند تو واقعاً همان کسی هستی که میگویی — این احراز هویت است. بعد اگر بخواهی بروی پشت صحنه، دستبند VIPات را نگاه میکنند — این مجوزدهی است. یکی میپرسد «تو کیستی؟»، دیگری میپرسد «اجازه داری این کار را بکنی؟». کاملاً دو سؤال جدا.
- احراز هویت — تو کیستی؟ یک شیء
Authentication(پرینسیپال + اعتبارنامه + اختیارات) تولید میکند که درSecurityContextHolder(بهطور پیشفرض یکThreadLocal) ذخیره میشود. - مجوزدهی — آیا اجازهٔ این کار را داری؟ مجموعهٔ
GrantedAuthorityپرینسیپالِ احرازشده را با یک قانون (قانون URL از طریقauthorizeHttpRequestsیا قانون متد از طریق@PreAuthorize) مقایسه میکند.
حالا یک تمایز ظریف اما حیاتی که سالانه هزاران ساعت دیباگ میبلعد: اختیارات (authorities) در برابر نقشها (roles). یک نقش صرفاً یک اختیار با پیشوند قراردادی ROLE_ است. یعنی نقش چیز خاصی نیست، فقط یک authority است که قرارداد شده جلویش ROLE_ بگذاریم. hasRole("ADMIN") پشتپرده اختیار ROLE_ADMIN را بررسی میکند؛ hasAuthority("ROLE_ADMIN") معادل آن است. اما hasAuthority("ADMIN") معادل نیست.
اگر authorityهای کاربر را بدون پیشوند ذخیره کنی (مثلاً ADMIN) اما با hasRole("ADMIN") محافظت کنی، Spring دنبال ROLE_ADMIN میگردد، پیدا نمیکند، و ۴۰۳ میدهد — بدون هیچ پیام واضحی. قاعده: hasRole("X") ↔ authority باید ROLE_X باشد. یا همهجا پیشوند ROLE_ بگذار، یا هیچجا و بهجایش از hasAuthority استفاده کن. فقط سازگار باش.
سبک پیکربندی Spring Security 6 — مبتنی بر کامپوننت، بدون Adapter
اگر آموزشهای قدیمی دیدهای، احتمالاً کلاسی دیدهای که WebSecurityConfigurerAdapter را extend میکرد و متدها را override میکرد. آن دوران تمام شده.
Spring Security 6 (بوت ۳، پایهٔ جاوا ۱۷) کلاس WebSecurityConfigurerAdapter را حذف کرد. دیگر یک کلاس پایه را extend نمیکنی و متدها را override نمیکنی؛ بهجای آن bean تعریف میکنی. Lambda DSL اکنون تنها سبک سازگار با آینده است (Spring Security 7 آن را الزامی میکند و شکل زنجیرهای قدیمی/and() را حذف خواهد کرد).
فلسفهٔ جدید ساده است: بهجای «من از یک کلاس والد ارث میبرم و رفتارش را دستکاری میکنم»، میگویی «من یک SecurityFilterChain میسازم و به Spring تحویل میدهم». این همان تفاوت میان دستکاری یک ماشین آماده و سرِهمکردن قطعات خودت است — شفافتر و قابلترکیبتر.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // جایگزین @EnableGlobalMethodSecurity؛ prePostEnabled بهطور پیشفرض true
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// requestMatchers جایگزین antMatchers/mvcMatchers شده (در ۶ حذف شدند)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.requestMatchers(HttpMethod.POST, "/api/orders").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // رد پیشفرض: این را آخر نگه دارید
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
// DelegatingPasswordEncoder: بهصورت {bcrypt}$2a$... ذخیره میکند تا بعداً بتوان الگوریتم را مهاجرت داد
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
بیا کد را بخوانیم: authorizeHttpRequests قوانین دسترسی را میچیند — از خاص به عام. permitAll() یعنی «همه بیایند»، hasRole("USER") یعنی «فقط USER»، و anyRequest().authenticated() در انتها یعنی «هر چیز دیگری، حداقل باید لاگین باشد». این آخری سیاستِ رد پیشفرض (deny by default) است و باید همیشه آخرین قانون باشد.
سه مورد حذف/تغییرنامیافته که باید حفظ باشی (اینها سؤال مصاحبهاند):
authorizeRequests()←authorizeHttpRequests()(نسخهٔ جدید بعد از dispatch سروِلت اجرا میشود و باAuthorizationManagerیکپارچه است).antMatchers()/mvcMatchers()/regexMatchers()←requestMatchers().@EnableGlobalMethodSecurity(prePostEnabled=true)←@EnableMethodSecurity(pre/post بهطور پیشفرض فعال؛ ازAuthorizationManagerاستفاده میکند).
چون حالا فقط bean تعریف میکنی، میتوانی چند bean از نوع SecurityFilterChain بسازی و آنها را با @Order و یک securityMatcher مرتب کنی. این الگوی کلاسیک برای جدا کردن زنجیرهٔ بدونحالتِ /api/** از زنجیرهٔ حالتدارِ رابط کاربری است — مثل داشتن دو درِ ورودی جدا برای مشتریهای عادی و کارمندان:
@Bean @Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**") // این زنجیره فقط /api/** را مدیریت میکند
.csrf(csrf -> csrf.disable()) // API بدونحالت، بخش CSRF را ببینید
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
@Order(1) یعنی این زنجیره اول بررسی میشود؛ securityMatcher("/api/**") یعنی فقط برای مسیرهای /api فعال است. اولین زنجیرهٔ منطبق برنده است و فقط فیلترهای همان زنجیره اجرا میشوند.
UserDetailsService و رمزگذاری پسورد
حالا سؤال: وقتی کاربر نامکاربری و پسورد میفرستد، Spring از کجا میداند این پسورد درست است؟
UserDetailsService مثل کتابداری است که وقتی اسم یک عضو را میدهی، میرود پروندهٔ او را از قفسه درمیآورد: «آها، ali، پسوردِ هششدهاش این است، نقشهایش اینهاست». کتابدار پسورد را چک نمیکند؛ فقط پرونده را میآورد. مقایسهٔ پسورد کارِ یک متخصصِ دیگر است: PasswordEncoder.
برای احراز هویت نامکاربری/پسورد، DaoAuthenticationProvider متد UserDetailsService#loadUserByUsername تو را صدا میزند و سپس مقایسهٔ پسورد را به PasswordEncoder واگذار میکند.
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository repo;
JpaUserDetailsService(UserRepository repo) { this.repo = repo; }
@Override
public UserDetails loadUserByUsername(String username) {
var u = repo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return User.withUsername(u.getUsername())
.password(u.getPasswordHash()) // از قبل یک هش {bcrypt} است
.authorities(u.getRoles().stream() // مثلاً "ROLE_USER"
.map(SimpleGrantedAuthority::new).toList())
.build();
}
}
دقت کن که ما هیچوقت پسوردِ خام را در دیتابیس ذخیره نمیکنیم — همیشه یک هش ذخیره میکنیم. هش یعنی یک تابع یکطرفه که از پسورد یک رشتهٔ درهم میسازد که برگرداندنش (بهلحاظ عملی) ممکن نیست.
تصور کن برای باز کردن هر گاوصندوق باید ۱۰ ثانیه دستگیره را بچرخانی. برای تو که یک بار در روز بازش میکنی، ۱۰ ثانیه هیچ است. اما برای دزدی که میخواهد یک میلیون رمز را امتحان کند، ۱۰ ثانیه × یک میلیون یعنی سالها. BCrypt عمداً کُند است — دقیقاً تا حملهکننده را زمینگیر کند، نه تو را.
BCrypt توصیهٔ پیشفرض است: یک هش عمداً کُند، نمکدار (salted) و تطبیقی (adaptive). «نمک» یعنی یک مقدار تصادفی که به پسورد اضافه میشود تا دو کاربر با پسورد یکسان، هشِ متفاوت بگیرند (جلوی جدولهای آماده را میگیرد). نمک درون خروجی جاسازی میشود ($2a$10$<۲۲ کاراکتر نمک><۳۱ کاراکتر هش>)، بنابراین هرگز ستون نمک جداگانه ذخیره نمیکنی. عدد 10 هزینه (work factor) است — هر +۱ کار را دو برابر میکند («تطبیقی» یعنی همین: با قویتر شدن سختافزار، عدد را بالا میبری). هنگام ثبتنام با passwordEncoder.encode(raw) رمزگذاری کن؛ هرگز هشها را خودت مقایسه نکن — از matches(raw, stored) استفاده کن که تقریباً زمانثابت است و پارامترها را از مقدار ذخیرهشده پارس میکند.
BCrypt ورودی را در ۷۲ بایت میبُرد. یعنی هر چیزی بعد از بایت هفتادودوم بیصدا نادیده گرفته میشود. عبارتهای عبورِ بلندتر از ۷۲ بایت پیشوند مشترک دارند و میتوانند تصادم کنند. اگر به ورودی بلند دلخواه نیاز داری، ابتدا با SHA-256 پیشهش کن یا از Argon2 (Argon2PasswordEncoder) / SCrypt استفاده کن.
چرا DelegatingPasswordEncoder؟ چون خروجیاش یک پیشوند مثل {bcrypt} دارد. این پیشوند به Spring میگوید هر هش با کدام الگوریتم ساخته شده، پس فردا میتوانی الگوریتم را عوض کنی بدون اینکه کاربران قدیمی بشکنند — هشهای قدیمی با پیشوند قدیمیشان همچنان چک میشوند.
جریان بدونحالت JWT
تا اینجا فرض کردیم سرور یک سشن نگه میدارد. اما اگر ۱۰ سرور داشته باشی و بخواهی کاربر به هرکدام بخورد کار کند، سشن دردسر میشود. اینجا JWT وارد میشود.
وقتی وارد یک جشنواره میشوی، دمِ در کارت شناساییات را چک میکنند و یک دستبند به دستت میبندند که رویش یک مُهرِ ضدِجعل دارد. از آن به بعد هیچ نگهبانی دوباره کارت شناساییات را نمیخواهد — فقط به دستبند و مهرش نگاه میکند. مهر را نمیشود جعل کرد، پس نگهبان بدون تماس با دفتر مرکزی مطمئن میشود دستبند معتبر است. JWT همان دستبند است: خودشمول، مهرشده، و هر نگهبانی مستقلاً میتواند اعتبارش را بسنجد.
یک JWT توکنی خودشمول و امضاشده است: header.payload.signature، با base64url انکد شده. سه بخش دارد که با نقطه جدا شدهاند: هدر (میگوید با چه الگوریتمی امضا شده)، payload (همان claimها یا ادعاها — مثل نام کاربر و نقشهایش)، و امضا (مُهر ضدِجعل).
امضاشده (JWS) ≠ رمزنگاریشده. هرکسی میتواند base64 را دیکد کند و claimها را بخواند — امضا فقط جلوی تغییر را میگیرد، نه خواندن را. پس هرگز راز (secret)، پسورد یا دادهٔ حساس در JWT نگذار. دستبندت را همه میبینند؛ فقط نمیتوانند مهرش را جعل کنند.
جریان بدونحالت اینطور کار میکند:
- کلاینت یک بار احراز هویت میکند (
POST /loginبا اعتبارنامه). - سرور تأیید میکند و سپس یک توکن دسترسی کوتاهعمر (JWT، مثلاً ۵–۱۵ دقیقه) صادر میکند که با یک راز (HMAC/
HS256) یا کلید خصوصی (RSA/EC،RS256/ES256) امضا شده است. - کلاینت در هر درخواست
Authorization: Bearer <token>میفرستد. - سرور در هر درخواست امضا و claimها را اعتبارسنجی میکند — بدون سشن، بدون کوئری دیتابیس. همین بیحالتی هدف اصلی است (مقیاسپذیری افقی، بدون سشن چسبنده).
نکتهٔ مهندسی مهم: ترجیح بده سرویس خودت را بهجای دستنویسیِ یک فیلتر، OAuth2 Resource Server تلقی کنی — حتی برای توکنهای خودصادرشده — تا اعتبارسنجی آزمودهشده را رایگان بگیری:
http.oauth2ResourceServer(o -> o.jwt(jwt -> jwt
.jwtAuthenticationConverter(authConverter())));
# RS256 با یک ارائهدهندهٔ خارجی/OIDC — کلیدها را از طریق /.well-known کشف میکند
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.example.com
# یا برای HS256 خودصادرشده، بهجای آن یک bean دیکودر بدهید:
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {
var key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
var decoder = NimbusJwtDecoder.withSecretKey(key).build();
// لایهبندی اعتبارسنجها: پیشفرض (exp/nbf) + issuer + audience
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("my-auth"),
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("my-api"))));
return decoder;
}
اینجا سهلایه اعتبارسنجی داریم: پیشفرض (exp انقضا و nbf یعنی «not before / پیش از این معتبر نیست»)، سپس چک issuer (چه کسی صادرش کرده)، سپس چک audience (aud یعنی این توکن برای چه سرویسی است). هر لایه یک درِ محکمتر.
حالا باید claimهای توکن را به authorityهای Spring نگاشت کنی. بهطور پیشفرض Spring، claim scope/scp را به اختیارات SCOPE_* نگاشت میکند. برای نگاشت یک claim سفارشی roles به ROLE_*:
JwtAuthenticationConverter authConverter() {
var granted = new JwtGrantedAuthoritiesConverter();
granted.setAuthoritiesClaimName("roles");
granted.setAuthorityPrefix("ROLE_");
var conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(granted);
return conv;
}
توکنهای refresh — جایی که مردم اشتباه میکنند
اینجا یک تناقضِ ظاهری هست: گفتیم توکن دسترسی کوتاهعمر است (۵–۱۵ دقیقه). ولی نمیخواهی هر ۱۵ دقیقه از کاربر پسورد بپرسی. راهحل: توکن refresh.
توکن دسترسی مثل کلیدِ اتاقِ هتل است — کوتاهعمر و اگر گم شود خطرناک. توکن refresh مثل قبضِ رختکن است که پشت پیشخوان با شمارهات ثبت شده. هر وقت کلیدت منقضی شد، قبض را نشان میدهی و کلید تازه میگیری. و چون هتل قبض را در دفترش دارد، هر لحظه میتواند بگوید «این قبض دیگر معتبر نیست» — یعنی قابلابطال است، برخلاف JWT بدونحالت.
توکنهای دسترسی باید کوتاهعمر باشند چون نمیتوانی یک JWT بدونحالت را قبل از انقضا باطل کنی. برای پرهیز از پرسیدن دوبارهٔ اعتبارنامه، در کنار آن یک توکن refresh بلندعمر صادر میکنی. ویژگیهای حیاتی:
- توکن refresh مبهم (opaque) و سمتسرور ذخیرهشده است (دیتابیس/Redis)، پس میتواند باطل شود. یک JWT نیست که کورکورانه به آن اعتماد کنی.
- هنگام refresh، آن را در برابر انبار اعتبارسنجی کن، سپس آن را بچرخان (rotate) (توکن refresh جدید صادر کن، قدیمی را باطل کن). چرخش امکان تشخیص سرقت را میدهد: اگر یک توکن refreshِ مصرفشده دوباره ارائه شود، کل خانواده را در معرض خطر تلقی و همه را باطل کن.
- توکن دسترسی: دقایق. توکن refresh: روزها/هفتهها.
توکنها را کجا ذخیره کنیم (مرورگر)
این یک سؤال طراحی امنیتی است، نه سؤال Spring:
| محل ذخیره | ریسک XSS | ریسک CSRF | یادداشت |
|---|---|---|---|
localStorage |
بالا — هر JS تزریقشده آن را میخواند | ندارد (خودکار ارسال نمیشود) | راحت اما یک XSS = سرقت کامل توکن |
کوکی HttpOnly Secure |
JS نمیتواند بخواند | دارد — باید دفاع CSRF افزود | در برابر XSS امنتر؛ نیاز به SameSite=Strict/Lax + توکن CSRF |
| در حافظه (متغیر JS) | با رفرش از دست میرود؛ پنجرهٔ کوچکتر | ندارد | توکن دسترسی در حافظه + توکن refresh در کوکی HttpOnly ترکیب قوی است |
اینجا XSS یعنی حملهای که کد JS مخرب را داخل صفحهٔ تو تزریق میکند؛ اگر توکن جایی باشد که JS بتواند بخواندش (مثل localStorage)، آن کد مخرب میتواند توکن را بدزدد.
توکن دسترسی در حافظه (یک متغیر JS)، توکن refresh در کوکی HttpOnly; Secure; SameSite با یک اندپوینت refreshِ خاموش (silent refresh). این ترکیب بهترینِ هر دو دنیاست: توکن دسترسی چون در حافظه است با XSS راحت لو نمیرود و با رفرش صفحه پاک میشود؛ توکن refresh چون HttpOnly است اصلاً برای JS نامرئی است. برای هرچیز بلندعمر از localStorage بپرهیز.
دامهای JWT
- نمیتوانی زودتر باطل کنی. لاگاوت فقط سمتکلاینت است مگر اینکه یک denylist سمتسرور بیفزایی (که حالت را دوباره وارد میکند). بهجای آن توکنهای دسترسی را کوتاه نگه دار.
alg: none— تاریخاً برخی کتابخانهها توکنهای بدونامضا را میپذیرفتند (یعنی مهاجم میگفت «الگوریتمم هیچ است» و بدون مهر رد میشد!). Spring/Nimbus این را رد میکند، اما هرگز نگذار توکن، الگوریتم را دیکته کند.- سردرگمی الگوریتم (algorithm confusion) — مهاجم یک توکن
RS256را با کلید عمومی بهعنوان راز HMAC، بهصورتHS256دوباره امضا میکند. الگوریتم مورد انتظار را پین کن؛ نگذار هدر انتخاب کند. - انحراف ساعت (clock skew) —
exp/nbfرا با اندکی اغماض اعتبارسنجی کن (NimbusJwtDecoderامکان پیکربندی skew ساعت را میدهد). دلیلش: ساعت سرورها دقیقاً یکی نیست و بدون اغماض، توکنی که همین الان صادر شده ممکن است «هنوز معتبر نیست» بگیرد. - حجیمشدن — گذاشتن نقشها/دسترسیها در توکن یعنی تا انقضا کهنه میمانند (اگر دسترسی کاربر را عوض کنی، توکن قدیمی هنوز دسترسی قدیم را دارد)، و توکنهای بزرگ به محدودیت اندازهٔ هدر میخورند.
نقشهای OAuth2 / OIDC
اسم OAuth2 خیلیها را میترساند، ولی هستهٔ ماجرا یک ایدهٔ ساده است: «چطور اجازه دهم اپِ B بهجای من به دادههای اپِ A دست بزند، بدون اینکه پسوردم را به B بدهم؟»
وقتی ماشینت را به کارگرِ پارکینگ میدهی، کلیدِ اصلی را نمیدهی — یک «کلید ویژهٔ پارک» میدهی که فقط ماشین را روشن میکند و در صندوق را باز نمیکند. OAuth2 دقیقاً همین است: مجوزدهی تفویضشده. تو (کاربر) به یک اپ اجازهٔ محدود میدهی بدون اینکه پسوردت را لو دهی. OIDC یک لایهٔ بالاتر است که علاوه بر «این اپ چه اجازهای دارد» میگوید «و این هم هویتِ خودِ کاربر» — از طریق یک id_token.
OAuth2 یک چارچوب مجوزدهی تفویضشده (delegated authorization) است؛ OIDC روی آن احراز هویت (identity) را از طریق id_token میافزاید. Spring Security سه نقش مجزا را پیاده میکند — مصاحبهکنندهها عاشق بررسی ایناند که آنها را قاطی نکنی:
| نقش | ماژول Spring | هدف |
|---|---|---|
| Authorization Server | Spring Authorization Server (پروژهٔ جدا) | توکن صادر میکند؛ /oauth2/authorize، /oauth2/token، JWKS. اکثر تیمها بهجایش Keycloak/Auth0/Okta استفاده میکنند. |
| Resource Server | spring-security-oauth2-resource-server + -jose |
API شما. توکنهای Bearer ورودی را اعتبارسنجی و scopeها را اعمال میکند. بدونحالت. |
| Client | spring-security-oauth2-client |
یک وباپ که توکنها را بهنمایندگی از کاربر میگیرد (Authorization Code + PKCE)، ذخیره میکند، و resource serverها را صدا میزند. |
سه واژه که مدام قاطی میشوند، scope در برابر claim در برابر authority:
- یک claim هر جفت کلید/مقدار در توکن است (
sub،iss،email،roles). یعنی هر «ادعا»یی که توکن دربارهٔ صاحبش میکند. - یک scope یک claim خاص (
scope) است که بیان میکند توکن مجاز به چه کاری است — درشتدانه، مبتنی بر رضایت کاربر. Spring آن را بهSCOPE_readو غیره نگاشت میکند. - authorities بازنمایی داخلی Spring است؛ تو تصمیم میگیری claimها/scopeها چطور به authority تبدیل شوند.
.requestMatchers("/api/reports/**").hasAuthority("SCOPE_reports:read")
و یک نکتهٔ مهم دربارهٔ جریان گرفتن توکن: جریان Authorization Code با PKCE امروزه جریان درست برای هم SPAها و هم وباپهای سمتسرور است (جریان implicit منسوخ است). PKCE (code_verifier/code_challenge) کد مجوز را به کلاینتی که جریان را شروع کرده گره میزند — مثل اینکه کدی که میگیری فقط با «قفلِ مخفیِ» خودت باز میشود — و از رهگیری کد جلوگیری میکند.
امنیت سطح متد — @PreAuthorize و دوستان
قوانین URL درشتاند: «هرکس به /admin میرود باید ADMIN باشد». اما بعضی قوانین ظریفترند: «کاربر فقط سندهای خودش را ببیند». اینجور قوانین را نزدیک به کد بیان میکنیم.
کنترل دسترسیِ سطح-URL مثل قفلِ درِ ورودیِ اتاق است. امنیت سطح متد مثل قفل روی تکتکِ کشوهای داخل اتاق است — حتی وقتی کسی وارد اتاق شد، هر کشو باز جداگانه چک میکند «آیا این کشو مالِ توست؟». @PreAuthorize همان قفلِ کشوست.
@EnableMethodSecurity انوتیشنهای مبتنی بر SpEL (زبان بیانِ Spring) را فعال میکند:
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public void purge() { /* ... */ }
// دسترسی به آرگومان متد و پرینسیپال از طریق SpEL
@PreAuthorize("#ownerId == authentication.name or hasRole('ADMIN')")
public List<Doc> listFor(String ownerId) { /* ... */ }
// @PostAuthorize شیء بازگشتی را بعد از اجرا فیلتر میکند
@PostAuthorize("returnObject.owner == authentication.name")
public Doc get(Long id) { /* ... */ }
// @PreFilter / @PostFilter عناصر کالکشن را فیلتر میکنند
@PostFilter("filterObject.visibility == 'PUBLIC' or filterObject.owner == authentication.name")
public List<Doc> search(String q) { /* ... */ }
}
ببین چه قدرتی دارد: #ownerId به آرگومان متد اشاره میکند و authentication.name به کاربر لاگینکرده — پس میتوانی بگویی «فقط اگر صاحبِ سند خودت باشی یا ادمین باشی». returnObject به مقدار بازگشتی و filterObject به تکتکِ عناصرِ لیست اشاره میکند.
امنیت سطح متد از پروکسیهای Spring AOP استفاده میکند: Spring یک لایهٔ پوششی دور bean تو میسازد و چک امنیتی را آنجا میگذارد. اما وقتی از داخلِ همان bean متد دیگری را با this.purge() صدا میزنی، تماس از پروکسی عبور نمیکند و انوتیشن نادیده گرفته میشود. اصلاح: متد محافظتشده را به bean دیگری ببر یا طوری بازساختاردهی کن که از بیرونِ bean وارد شود.
دو نکتهٔ دیگر که سنیورها را از جونیورها جدا میکند:
@PostAuthorizeبعد از اجرای متد اجرا میشود — هر اثر جانبی از قبل رخ داده (رکورد حذف شده، ایمیل رفته). هرگز از آن برای محافظت یک متد تغییردهنده استفاده نکن؛ فقط برای فیلترِ خروجیِ متدهای فقطخواندنی.@EnableMethodSecurityرا بر@Secured/JSR-250 منسوخ ترجیح بده مگر نیاز خاص داشته باشی؛@PreAuthorizeبا SpEL اکیداً رساتر است.
CSRF — چه زمانی واقعاً اهمیت دارد
CSRF یکی از آن مباحثی است که همه اسمش را شنیدهاند ولی کمتر کسی میداند دقیقاً کِی اهمیت دارد.
تصور کن بانک تو، هر کسی که «مُهرِ تو» را روی برگه دارد قبول میکند و کوکیِ نشستِ تو همان مُهر است که مرورگر خودکار روی هر نامه به بانک میزند. حالا یک سایتِ مخرب یک نامهٔ جعلی «۱۰۰۰ دلار به من بده» میسازد و از مرورگرِ تو میفرستد — و مرورگر مطیعانه مُهرت را رویش میزند. بانک قبول میکند! این CSRF است: سوءاستفاده از اعتبارنامهای که مرورگر خودکار ضمیمه میکند.
CSRF (جعل درخواست بینسایتی) از اعتبارنامههای محیطی که مرورگر خودکار میفرستد — عمدتاً کوکیها — سوءاستفاده میکند. اگر اپ تو از طریق کوکی سشن یا هر کوکی خودارسال احراز هویت میکند، یک سایت مخرب میتواند از مرورگر قربانی یک درخواست تغییردهنده را تحریک کند و کوکی همراهش میرود.
- احراز هویت مبتنی بر کوکی/سشن ← محافظت CSRF روشن.
CsrfFilterپیشفرض Spring رویPOST/PUT/PATCH/DELETEیک توکن میخواهد. به همین دلیل غیرفعال کردن CSRF در یک اپ فرملاگین، لاگین/فرمها را میشکند. - توکن بدونحالت در هدر
Authorization← CSRF بیربط است، غیرفعالش کن. مرورگر هدرAuthorization: Bearerرا خودکار الصاق نمیکند، پس درخواست جعلی مهاجم نمیتواند شامل توکن باشد. از این رو.csrf(c -> c.disable())فراگیر روی APIهای JWT — آنجا درست است، جای دیگر خطرناک.
ظرافتی که خیلیها را میاندازد: اگر API «بدونحالت» تو JWT را در کوکی ذخیره کند، CSRF دوباره روی میز است — حالا مرورگر خودکارش میفرستد. از کوکیهای SameSite و توکنهای CSRF استفاده کن.
Spring 6 از CsrfTokenRequestAttributeHandler و (برای SPAها) الگوی CookieCsrfTokenRepository.withHttpOnlyFalse() استفاده میکند که JS توکن را از کوکی میخواند و در یک هدر بازتاب میدهد.
CORS — یک سیاست مرورگری، نه یک کنترل امنیتی
این شاید بیش از هر مبحث دیگری اشتباه فهمیده میشود. بگذار همین اول صریح بگویم: CORS تو را امن نمیکند.
CORS مثل نگهبانی است که فقط داخلِ مرورگر ایستاده و تصمیم میگیرد آیا کدِ JavaScriptِ یک سایت میتواند پاسخِ سایتِ دیگری را بخواند. اما این نگهبان اصلاً روی سرورِ تو نیست! یک ابزار مثل curl یا یک سرورِ دیگر کاملاً بیخیالِ این نگهبان است و مستقیم به دادهات میرسد. پس CORS یک قفلِ امنیتی نیست؛ فقط یک سیاستِ مرورگری است.
CORS (اشتراک منابع بینمبدأ) توسط مرورگر اعمال میشود و سیاست هممبدأ (Same-Origin Policy) را برای خواندنها شل میکند. این نه احراز هویت است و نه مجوزدهی — فقط تعیین میکند آیا JS مرورگر میتواند یک پاسخ بینمبدأ را بخواند. آن را در Spring پیکربندی کن تا درخواستهای preflight (OPTIONS) موفق شوند (preflight یعنی درخواستِ آزمایشیِ OPTIONS که مرورگر قبل از درخواستِ اصلی میفرستد تا بپرسد «اجازه دارم؟»):
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults()); // bean زیرِ CorsConfigurationSource را برمیدارد
return http.build();
}
@Bean
CorsConfigurationSource corsSource() {
var cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.example.com")); // هرگز "*" همراه credentials
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setAllowCredentials(true);
var src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/**", cfg);
return src;
}
به ترتیب توجه کن: CORS را در Spring Security فعال کن (http.cors(...)) تا CorsFilter قبل از احراز هویت اجرا شود و درخواستهای preflight OPTIONS (که هیچ اعتبارنامهای ندارند) را رد نکند. اگر این ترتیب را رعایت نکنی، preflight به فیلتر احراز هویت میخورد، ۴۰۱ میگیرد، و مرورگر درخواستِ اصلی را اصلاً نمیفرستد.
دامهای رایج و بهترینروشها
همیشه فرض کن هر درِ نامشخص باید بسته باشد. authorizeHttpRequests را با .anyRequest().authenticated() (یا .denyAll()) پایان بده. اگر فردا یک اندپوینت جدید اضافه کردی و یادت رفت قانون برایش بنویسی، بهتر است ناخواسته بسته بماند تا ناخواسته باز.
- رد پیشفرض. یک اندپوینت فراموششده باید بسته (fail closed) شکست بخورد.
permitAll()احراز هویت را حذف نمیکند — درخواست را اجازه میدهد؛SecurityContextممکن است همچنان پُر باشد. وpermitAll()روی یک مسیر کمکی نمیکند اگر یک matcher بعدیِ خاصتر اصلاً به آن نرسد — قوانین از بالا به پایین ارزیابی میشوند، اولین تطابق برنده است. قوانین خاص را قبل از عام بگذار.- افشا نکن که آیا نامکاربری وجود دارد — برای کاربر بد و پسورد بد همان خطا را برگردان.
UsernameNotFoundExceptionبهطور پیشفرض بهBadCredentialsExceptionنگاشت میشود (hideUserNotFoundExceptions=true)؛ همینطور نگهش دار. - کلیدهای امضا را بچرخان و از key ID (
kid) پشتیبانی کن تا بتوانی کلیدهای RSA را بدون قطعی بچرخانی (JWKS این را برای resource serverها خودکار میکند). - resource-server + IdP خارجی را بر ساختن auth server خودت ترجیح بده مگر دلیل قوی داشته باشی.
SecurityContextHolderیک ThreadLocal است.
SecurityContextHolder بهطور پیشفرض MODE_THREADLOCAL است — یعنی هویت به همان تِردی که درخواست را گرفته چسبیده و به @Async/تِردهای فرزند یا پایپلاینهای reactive منتشر نمیشود. داخل یک متد @Async، فراخوانی SecurityContextHolder.getContext().getAuthentication() مقدار null میدهد و کدت مرموزانه میشکند. درستکردنش: از DelegatingSecurityContextExecutor استفاده کن یا MODE_INHERITABLETHREADLOCAL بگذار، و در WebFlux از ReactiveSecurityContextHolder واکنشی.
سؤالات مصاحبه
FilterChainProxy زنجیرهٔ منطبق را انتخاب میکند. SecurityContextHolderFilter هیچ کانتکست سشنی نمییابد. BearerTokenAuthenticationFilter توکن را استخراج میکند و به JwtAuthenticationProvider میدهد که با JwtDecoder امضا + exp/nbf/iss را تأیید میکند، claimها را با JwtAuthenticationConverter به authority تبدیل میکند و Authentication را در کانتکست ذخیره میکند. در نهایت AuthorizationFilter، authorizeHttpRequests/@PreAuthorize را ارزیابی میکند. در شکست، ExceptionTranslationFilter یا ۴۰۱ (احرازنشده) یا ۴۰۳ (احرازشده اما ممنوع) برمیگرداند.
hasRole("ADMIN") اختیار ROLE_ADMIN را (پیشوندگذاری خودکار) بررسی میکند. hasAuthority("ADMIN") عیناً ADMIN را بررسی میکند. اگر authorityها را بهصورت ADMIN (بدون پیشوند) ذخیره کنی و با hasRole محافظت کنی، ۴۰۳ میگیری. authorityهای UserDetails/JWT و قوانینت را دربارهٔ پیشوند ROLE_ سازگار نگه دار.
یک JWT بدونحالت تا exp بدون توجه به حالت سرور معتبر است — بدون افزودن حالت سمتسرور (denylist) که بیحالتی را نقض میکند، نمیتوانی باطلش کنی. پس توکنهای دسترسی را چند دقیقهای نگه دار و به یک توکن refreshِ قابلابطالِ ذخیرهشده در سرور تکیه کن. «لاگاوت» توکن refresh را سمتسرور حذف و توکن دسترسی را سمتکلاینت دور میریزد؛ توکن دسترسی تا انقضا هنوز فنی کار میکند، به همین دلیل پنجره باید کوچک باشد.
مهاجم یک توکن RS256 را میگیرد، هدر را به HS256 تغییر میدهد و آن را با کلید عمومی RSA شما بهعنوان راز HMAC امضا میکند. اگر سرور الگوریتم را از هدر توکن برگزیند، با موفقیت تأیید میشود. پیشگیری: الگوریتم(های) مورد انتظار را در دیکودر پین کن؛ هرگز نگذار توکن ورودی الگوریتم تأیید را دیکته کند. resource server نیمباس/Spring هنگام پیکربندی با نوع کلید مشخص، این را بهطور پیشفرض درست انجام میدهد.
برای APIهای بدونحالتِ احرازشده از طریق هدر Authorization غیرفعالش کن — مرورگر آن هدر را خودکار الصاق نمیکند، پس CSRF ناممکن است. برای هر احراز هویت مبتنی بر کوکی/سشن، از جمله JWT ذخیرهشده در کوکی، روشن نگهش دار، چون مرورگر کوکیها را خودکار میفرستد و درخواست بینسایتی آنها را حمل میکند.
نه. CORS شلسازی مرورگری سیاست هممبدأ است که تعیین میکند آیا JS بینمبدأ میتواند یک پاسخ را بخواند. کسی را احراز هویت یا مجوزدهی نمیکند؛ یک کلاینت غیرمرورگری (curl، سرور) کاملاً نادیدهاش میگیرد. هرگز برای محافظت داده به CORS تکیه نکن — مجوزدهی را سمتسرور اعمال کن.
@Service
class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { /* ... */ }
public void cleanup() { deleteAll(); } // از یک اندپوینت permitAll صدا زده میشود
}
cleanup() متد deleteAll() را از طریق this (فراخوانی داخلی) صدا میزند و پروکسی AOP را دور میزند، پس @PreAuthorize هرگز اجرا نمیشود. هر کاربری که به cleanup() برسد همهٔ سفارشها را حذف میکند. اصلاح: deleteAll را به bean دیگری ببر، تزریق و از طریق پروکسی صدا بزن، یا بازساختاردهی کن تا متد محافظتشده از بیرون bean وارد شود.
User.withUsername("kai").password("secret").roles("USER").build();
هنگام احراز هویت (یا استارتاپ) خطا میدهد چون پسورد رمزگذاری نشده — با DelegatingPasswordEncoder، رشتهٔ secret پیشوند {id} ندارد، پس matches با «There is no PasswordEncoder mapped for the id 'null'» شکست میخورد. باید آن را رمزگذاری کنی ({bcrypt}$2a$...) یا صریحاً {noop} بگذاری (فقط توسعه). توجه: .roles("USER") پیشوند ROLE_ را خودکار میافزاید و اختیار ROLE_USER میسازد.
@PreAuthorize قبل از متد ارزیابی میشود — برای گیتکردن اجرا. @PostAuthorize بعد ارزیابی میشود، با دسترسی به returnObject — برای فیلتر آنچه بازگردانده میشود بر اساس نتیجه. روی هر متد دارای اثر جانبی خطرناک است: بدنه از قبل اجرا شده (رکوردها حذف، ایمیلها ارسال) پیش از اینکه مجوزدهی شکست بخورد، پس فقط باید متدهای فقطخواندنی را محافظت کند.
Authorization Server توکن صادر میکند (Keycloak/Auth0 یا Spring Authorization Server). Resource Server، API شماست که توکنهای Bearer ورودی را اعتبارسنجی و scopeها را اعمال میکند. Client اپی است که توکنها را بهنمایندگی از کاربر میگیرد (Authorization Code + PKCE) تا resource serverها را صدا بزند.
scopeها توصیف میکنند توکن/کلاینت مجاز به چه کاری است (درشت، مبتنی بر رضایت، از authorization server): SCOPE_orders:read. roleها توصیف میکنند کاربر کیست در دامنهٔ شما: ROLE_ADMIN. یک توکن میتواند هر دو را حمل کند؛ scopeها را در مرز API و roleها/دسترسیهای ریزتر را در امنیت سطح متد اعمال کن. یکی را بهمعنای دیگری بار نکن.
authorizeHttpRequests از بالا به پایین تطابق میدهد، اولین تطابق برنده است. anyRequest() با همهچیز منطبق میشود، پس هر قانون بعد از آن کد مرده است. matcherهای خاص را اول و catch-all (anyRequest().authenticated()/denyAll()) را آخر بگذار.
BCrypt یک نمک تصادفی ۱۲۸ بیتی را درون رشتهٔ خروجی جاسازی میکند ($2a$<هزینه>$<نمک><هش>)، پس ستون نمک جداگانه لازم نیست؛ matches از آن بازمشتق میکند. تله: BCrypt فقط ۷۲ بایت اول ورودی را هش میکند و بقیه را بیصدا نادیده میگیرد — عبارتهای عبور بلند میتوانند روی پیشوندشان تصادم کنند. برای ورودی بلند، پیشهش کن (SHA-256 → base64) یا از Argon2/SCrypt استفاده کن.
دو bean از نوع SecurityFilterChain تعریف کن، هرکدام با @Order مرتب و با securityMatcher("/api/**") محدود. اولین زنجیرهٔ منطبق برنده است؛ زنجیرهٔ /api بدونحالت است (JWT، CSRF خاموش)، زنجیرهٔ UI حالتدار (سشن، CSRF روشن، فرملاگین). فقط فیلترهای زنجیرهٔ منطبق اجرا میشوند.
استراتژی پیشفرض MODE_THREADLOCAL است، پس SecurityContext به تِرد درخواست مقید است و در تِردهای اجراکنندهٔ @Async دیده نمیشود — SecurityContextHolder.getContext().getAuthentication() آنجا null برمیگرداند. با DelegatingSecurityContextExecutor/DelegatingSecurityContextAsyncTaskExecutor درستش کن، یا MODE_INHERITABLETHREADLOCAL بگذار (به تِردهای فرزند منتشر میشود اما نه استخری — رَپرِ executor گزینهٔ مقاوم است). در WebFlux، کانتکست در Context راکتور زندگی میکند و از طریق ReactiveSecurityContextHolder دسترسی میشود.
Spring Security جادو نیست — یک زنجیره فیلتر است که فیلترهای احراز هویت (تو کیستی؟) قبل از AuthorizationFilter (اجازه داری؟) اجرا میشوند. احراز هویت هویت را در SecurityContextHolder (یک ThreadLocal) میگذارد؛ مجوزدهی آن را با قوانین میسنجد — و یادت باشد hasRole("X") یعنی authority ROLE_X. در Spring Security 6 دیگر Adapter نیست؛ bean بساز، از authorizeHttpRequests/requestMatchers/@EnableMethodSecurity استفاده کن و با رد پیشفرض تمام کن. پسورد را با BCrypt (نمکِ جاسازیشده، تلهٔ ۷۲ بایتی) هش کن. برای APIهای بدونحالت، JWT (امضاشده ≠ رمزنگاریشده) با توکن دسترسیِ کوتاهعمر + توکن refreshِ قابلابطالِ سمتسرور به کار ببر و ترجیحاً بهعنوان Resource Server پیکربندی کن. سه نقش OAuth2/OIDC (Authorization Server / Resource Server / Client) را قاطی نکن و scope را با role اشتباه نگیر. امنیت سطح متد قوی است اما با فراخوانی داخلی دور زده میشود و @PostAuthorize روی متدهای تغییردهنده خطرناک است. CSRF فقط با اعتبارنامهٔ خودارسالِ کوکی مهم است؛ CORS اصلاً امنیت نیست، فقط سیاست مرورگر. با اینها، هم سیستمِ امن میسازی و هم از پس سؤالات سنیور برمیآیی.
Most people picture Spring Security as a magical black box that "somehow" keeps people out. The truth is far more calming: it's a line of guards standing one behind another, each doing one small job. Once you hold that picture, everything else — JWT, OAuth2, CSRF — becomes just details of that line. Let's build it from scratch.
First we build a mental model: Spring Security is a chain of filters. Then we nail down the difference between authentication and authorization. Next the Spring Security 6 config style (no Adapter, Lambda DSL), then UserDetailsService and BCrypt for passwords, then the stateless JWT flow with refresh tokens and where to store them, then the three OAuth2/OIDC roles, then method security, and finally the two topics everyone gets wrong: CSRF and CORS. We close with 15 interview questions and full answers.
Part 0 — words you must know
Before we start, let's unpack a few terms with a quick analogy so we don't trip on them later:
- Servlet Filter: in Java web apps, every HTTP request passes through a chain of "filters" before it reaches your code. A filter is like a doorman who can inspect, modify, reject, or wave the request through. Spring Security is nothing but a few clever filters.
- Principal: "the entity currently speaking" — usually the logged-in user. The name comes from logic, but it just means "the current identity."
- Authority / GrantedAuthority: a permission label stuck to the user, like
ROLE_ADMINorSCOPE_read. The set of these labels decides what the user can do. - Token: a portable piece of data that says "my holder is authenticated." Instead of resending a password every time, you flash this card.
- Stateless: the server keeps no memory of you between two requests. Each request must be complete and self-proving on its own.
With those in hand, let's get to the heart of it.
Mental model: it's a filter chain, not magic
Imagine walking into an airport. There's a corridor with back-to-back stations: ticket check, then passport control, then the body scanner, then the boarding gate. Each station does one small job and sends you to the next. Fail one, and you're stopped right there. Spring Security is exactly that corridor — a chain of checkpoints an HTTP request passes through.
At its core, Spring Security is a single servlet Filter (DelegatingFilterProxy → FilterChainProxy) that Spring Boot registers early in the container's filter list. Every HTTP request passes through this proxy, which selects one SecurityFilterChain (the first whose RequestMatcher matches) and runs its ordered list of internal filters. Nothing about Spring Security is aspect-oriented at the web layer — it is plain servlet filters executing in a fixed order.
That order matters and interviewers probe it. A simplified, representative slice of the default chain:
DisableEncodeUrlFilter
SecurityContextHolderFilter // loads/saves SecurityContext (was SecurityContextPersistenceFilter)
CsrfFilter // validates CSRF token for state-changing methods
LogoutFilter
UsernamePasswordAuthenticationFilter // processes form login POST /login
...
BearerTokenAuthenticationFilter // OAuth2 resource server: extracts Bearer token
...
ExceptionTranslationFilter // catches AuthenticationException / AccessDeniedException
AuthorizationFilter // the LAST filter: enforces authorizeHttpRequests rules
Let's read that line by line. The names look scary, but each does something simple: SecurityContextHolderFilter runs first to pull out a stored identity if a session exists. CsrfFilter checks the anti-forgery token. UsernamePasswordAuthenticationFilter handles the login form. BearerTokenAuthenticationFilter pulls the JWT out of the header. And at the very end, AuthorizationFilter makes the final access decision.
Here's the key insight that makes everything click: authentication filters run before AuthorizationFilter. An authentication filter's job is only to populate the SecurityContextHolder with an Authentication; it does not decide access. The final AuthorizationFilter reads that Authentication and applies your rules.
The early guards only pick up your identity and jot it on a slip ("this is user ali with role USER"). They never say "come in" or "no." Only the last guard reads that slip and decides. If the slip is blank and the path is protected → rejected.
If a request reaches AuthorizationFilter unauthenticated and hits a protected rule, an AccessDeniedException is thrown, caught by ExceptionTranslationFilter, which then either starts authentication (redirect to login / send 401) or returns 403. Keep the 401-vs-403 distinction in mind: 401 means "I don't know who you are" (unauthenticated) and 403 means "I know who you are, but you're not allowed" (authenticated but forbidden).
Authentication vs authorization
Two separate things happen at the concert door. First they check your ID to confirm you really are who you claim — that's authentication. Then, if you want backstage, they look at your VIP wristband — that's authorization. One asks "who are you?", the other asks "are you allowed to do this?". Two entirely separate questions.
- Authentication — who are you? Produces an
Authenticationobject (principal + credentials + authorities) stored inSecurityContextHolder(aThreadLocalby default). - Authorization — are you allowed to do this? Compares the authenticated principal's
GrantedAuthorityset against a rule (URL rule viaauthorizeHttpRequests, or method rule via@PreAuthorize).
Now a subtle but crucial distinction that swallows thousands of debugging hours a year: authorities vs roles. A role is just an authority with the conventional ROLE_ prefix. That's it — a role is nothing special, just an authority we've agreed to prefix with ROLE_. hasRole("ADMIN") checks under the hood for authority ROLE_ADMIN; hasAuthority("ROLE_ADMIN") is equivalent. hasAuthority("ADMIN") is not.
If you store authorities without the prefix (e.g. ADMIN) but guard with hasRole("ADMIN"), Spring looks for ROLE_ADMIN, doesn't find it, and returns 403 — with no clear message. The rule: hasRole("X") ↔ the authority must be ROLE_X. Either prefix ROLE_ everywhere, or nowhere and use hasAuthority instead. Just be consistent.
Spring Security 6 config style — component-based, no adapter
If you've seen old tutorials, you've probably seen a class that extended WebSecurityConfigurerAdapter and overrode methods. That era is over.
Spring Security 6 (Boot 3, baseline Java 17) removed WebSecurityConfigurerAdapter. You no longer extend a base class and override methods; instead you declare beans. The Lambda DSL is now the only forward-compatible style (Spring Security 7 will require it and remove the old chained/and() form).
The new philosophy is simple: instead of "I inherit from a parent class and tweak its behavior," you say "I build a SecurityFilterChain and hand it to Spring." That's the difference between tuning a pre-built machine and assembling your own parts — clearer and more composable.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // replaces @EnableGlobalMethodSecurity; prePostEnabled=true by default
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// requestMatchers replaces antMatchers/mvcMatchers (removed in 6)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.requestMatchers(HttpMethod.POST, "/api/orders").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // deny-by-default: keep this last
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
// DelegatingPasswordEncoder: stores {bcrypt}$2a$... so you can migrate algorithms later
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Let's read it: authorizeHttpRequests lays out access rules — from specific to general. permitAll() means "everyone in," hasRole("USER") means "USER only," and the final anyRequest().authenticated() means "anything else must at least be logged in." That last one is the deny-by-default policy and must always be the final rule.
Three removed/renamed items to know cold (these are interview fodder):
authorizeRequests()→authorizeHttpRequests()(the new one runs after the servlet dispatch, integrates withAuthorizationManager).antMatchers()/mvcMatchers()/regexMatchers()→requestMatchers().@EnableGlobalMethodSecurity(prePostEnabled=true)→@EnableMethodSecurity(pre/post enabled by default; usesAuthorizationManager).
Because you now just declare beans, you can define multiple SecurityFilterChain beans and order them with @Order and a securityMatcher — the classic pattern for splitting a stateless /api/** chain from a stateful UI chain, like having two separate entrance doors for customers and staff:
@Bean @Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**") // this chain only handles /api/**
.csrf(csrf -> csrf.disable()) // stateless API, see CSRF section
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
@Order(1) means this chain is checked first; securityMatcher("/api/**") means it's only active for /api paths. The first matching chain wins, and only that chain's filters run.
UserDetailsService and password encoding
Now a question: when a user sends a username and password, how does Spring know the password is correct?
UserDetailsService is like a librarian who, when you give a member's name, fetches their file from the shelf: "ah, ali, here's his hashed password, here are his roles." The librarian doesn't check the password; she only fetches the file. Comparing the password is a different specialist's job: the PasswordEncoder.
For username/password auth, the DaoAuthenticationProvider calls your UserDetailsService#loadUserByUsername, then delegates password comparison to the PasswordEncoder.
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository repo;
JpaUserDetailsService(UserRepository repo) { this.repo = repo; }
@Override
public UserDetails loadUserByUsername(String username) {
var u = repo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return User.withUsername(u.getUsername())
.password(u.getPasswordHash()) // already a {bcrypt} hash
.authorities(u.getRoles().stream() // e.g. "ROLE_USER"
.map(SimpleGrantedAuthority::new).toList())
.build();
}
}
Notice we never store the raw password in the database — we always store a hash. A hash is a one-way function that turns the password into a scrambled string that (practically) cannot be reversed.
Imagine every safe takes 10 seconds of cranking the dial to open. For you, opening it once a day, 10 seconds is nothing. But for a thief trying a million guesses, 10 seconds × a million means years. BCrypt is deliberately slow — precisely to grind the attacker to a halt, not you.
BCrypt is the default recommendation: it's a deliberately slow, salted, adaptive hash. "Salted" means a random value is mixed into the password so two users with the same password get different hashes (this defeats precomputed tables). The salt is embedded in the output ($2a$10$<22-char-salt><31-char-hash>), so you never store a separate salt column. The 10 is the cost (work factor) — each +1 doubles the work ("adaptive" means exactly this: as hardware gets faster, you raise the number). Encode on registration with passwordEncoder.encode(raw); never compare hashes yourself — use matches(raw, stored), which is constant-time-ish and parses the parameters from the stored value.
BCrypt truncates input at 72 bytes. Anything past the seventy-second byte is silently ignored. Passphrases longer than 72 bytes share a prefix and can collide. If you need arbitrarily long inputs, pre-hash with SHA-256 or use Argon2 (Argon2PasswordEncoder) / SCrypt.
Why DelegatingPasswordEncoder? Because its output carries a prefix like {bcrypt}. That prefix tells Spring which algorithm produced each hash, so tomorrow you can switch algorithms without breaking old users — old hashes are still checked with their old prefix.
Stateless JWT flow
So far we assumed the server keeps a session. But if you have 10 servers and want a user to work no matter which one they hit, sessions become a headache. This is where JWT enters.
When you enter a festival, they check your ID at the door and strap on a wristband bearing a tamper-proof stamp. From then on no guard asks for your ID again — they just glance at the wristband and its stamp. The stamp can't be forged, so a guard is sure it's valid without phoning HQ. A JWT is that wristband: self-contained, stamped, and any guard can independently verify it.
A JWT is a self-contained, signed token: header.payload.signature, base64url-encoded. It has three dot-separated parts: the header (which algorithm signed it), the payload (the claims — like the user's name and roles), and the signature (the tamper-proof stamp).
Signed (JWS) ≠ encrypted. Anyone can base64-decode and read the claims — the signature only prevents changing them, not reading them. So never put a secret, password, or sensitive data in a JWT. Everyone sees your wristband; they just can't forge its stamp.
The stateless flow works like this:
- Client authenticates once (
POST /loginwith credentials). - Server verifies, then issues a short-lived access token (JWT, e.g. 5–15 min) signed with a secret (HMAC/
HS256) or private key (RSA/EC,RS256/ES256). - Client sends
Authorization: Bearer <token>on every request. - Server validates the signature and claims on each request — no session, no DB lookup. That statelessness is the whole point (horizontal scaling, no sticky sessions).
An important engineering note: prefer treating your own service as an OAuth2 Resource Server rather than hand-rolling a filter, even for self-issued tokens — you get battle-tested validation for free:
http.oauth2ResourceServer(o -> o.jwt(jwt -> jwt
.jwtAuthenticationConverter(authConverter())));
# RS256 with an external/OIDC provider — discovers keys via /.well-known
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.example.com
# or, for self-issued HS256, provide a decoder bean instead:
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {
var key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
var decoder = NimbusJwtDecoder.withSecretKey(key).build();
// Layer validators: default (exp/nbf) + issuer + audience
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("my-auth"),
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("my-api"))));
return decoder;
}
Here we have three layers of validation: the default (exp expiry and nbf, "not before" — not valid before this time), then an issuer check (who minted it), then an audience check (aud — which service this token is for). Each layer, a sturdier door.
Now you map the token's claims to Spring authorities. By default Spring maps the scope/scp claim to SCOPE_* authorities. To map a custom roles claim to ROLE_*:
JwtAuthenticationConverter authConverter() {
var granted = new JwtGrantedAuthoritiesConverter();
granted.setAuthoritiesClaimName("roles");
granted.setAuthorityPrefix("ROLE_");
var conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(granted);
return conv;
}
Refresh tokens — the part people get wrong
Here's an apparent contradiction: we said the access token is short-lived (5–15 min). But you don't want to ask the user for a password every 15 minutes. The solution: the refresh token.
The access token is like a hotel room key — short-lived and dangerous if lost. The refresh token is like a coat-check ticket, registered behind the counter under your number. Whenever your key expires, you show the ticket and get a fresh key. And because the hotel holds the ticket in its ledger, it can at any moment declare "this ticket is no longer valid" — meaning it's revocable, unlike a stateless JWT.
Access tokens must be short-lived because you can't revoke a stateless JWT before it expires. To avoid re-prompting for credentials, issue a long-lived refresh token alongside. Critical properties:
- The refresh token is opaque and stored server-side (DB/Redis), so it can be revoked. It is not a JWT you blindly trust.
- On refresh, validate it against the store, then rotate it (issue a new refresh token, invalidate the old). Rotation lets you detect theft: if an already-used refresh token is presented again, treat the family as compromised and revoke all.
- Access token: minutes. Refresh token: days/weeks.
Where to store tokens (browser)
This is a security-design question, not a Spring one:
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage |
High — any injected JS reads it | None (not auto-sent) | Convenient but a single XSS = full token theft |
HttpOnly Secure cookie |
JS can't read it | Yes — must add CSRF defense | Safer against XSS; needs SameSite=Strict/Lax + CSRF token |
| In-memory (JS variable) | Lost on refresh; smaller window | None | Access token in memory + refresh token in HttpOnly cookie is a strong combo |
Here XSS means an attack that injects malicious JS into your page; if the token lives somewhere JS can read it (like localStorage), that malicious code can steal it.
Access token in memory (a JS variable), refresh token in an HttpOnly; Secure; SameSite cookie, with a silent-refresh endpoint. This combo is the best of both worlds: the access token, being in memory, isn't easily leaked by XSS and clears on page refresh; the refresh token, being HttpOnly, is invisible to JS entirely. Avoid localStorage for anything long-lived.
JWT pitfalls
- You cannot revoke early. Logout is client-side only unless you add a server-side denylist (which reintroduces state). Keep access tokens short instead.
alg: none— historically, some libraries accepted unsigned tokens (i.e. an attacker declares "my algorithm is none" and sails through unstamped!). Spring/Nimbus rejects this, but never allow the token to dictate the algorithm.- Algorithm confusion — an attacker resigns an
RS256token asHS256using the public key as the HMAC secret. Pin the expected algorithm; don't let the header choose. - Clock skew — validate
exp/nbfwith a small leeway (NimbusJwtDecoderallows configuring the clock skew). Why: server clocks aren't perfectly synced, and without leeway a just-issued token can be rejected as "not yet valid." - Bloat — putting roles/permissions in the token means they're stale until expiry (change a user's access and the old token still carries the old access), and large tokens hit header-size limits.
OAuth2 / OIDC roles
The name OAuth2 scares many, but the core is a simple idea: "how do I let app B touch app A's data on my behalf, without handing my password to B?"
When you hand your car to a parking valet, you don't give the master key — you give a "valet key" that only starts the car and won't open the trunk. OAuth2 is exactly this: delegated authorization. You (the user) grant an app limited access without leaking your password. OIDC is a layer on top that, besides "what this app is allowed to do," also tells "and here's the user's identity" — via an id_token.
OAuth2 is a delegated authorization framework; OIDC layers authentication (identity) on top of it via the id_token. Spring Security implements three distinct roles — interviewers love checking you don't conflate them:
| Role | Spring module | Purpose |
|---|---|---|
| Authorization Server | Spring Authorization Server (separate project) | Issues tokens; runs /oauth2/authorize, /oauth2/token, JWKS. Most teams use Keycloak/Auth0/Okta instead. |
| Resource Server | spring-security-oauth2-resource-server + -jose |
Your API. Validates incoming Bearer tokens, enforces scopes. Stateless. |
| Client | spring-security-oauth2-client |
A web app that obtains tokens on a user's behalf (Authorization Code + PKCE), stores them, calls resource servers. |
Three words that constantly get muddled, scope vs claim vs authority:
- A claim is any key/value in the token (
sub,iss,email,roles) — any "assertion" the token makes about its holder. - A scope is a specific claim (
scope) expressing what the token is permitted to do — coarse-grained, consent-driven. Spring maps it toSCOPE_readetc. - Authorities are Spring's internal representation; you decide how claims/scopes become authorities.
.requestMatchers("/api/reports/**").hasAuthority("SCOPE_reports:read")
And an important note about how you obtain a token: the Authorization Code flow with PKCE is the correct flow for both SPAs and server-side web apps today (the implicit flow is deprecated). PKCE (code_verifier/code_challenge) binds the authorization code to the client that started the flow — as if the code you receive only unlocks with your own "secret padlock" — preventing code interception.
Method security — @PreAuthorize and friends
URL rules are coarse: "anyone going to /admin must be ADMIN." But some rules are finer: "a user may see only their own documents." We express such rules close to the code.
URL-level access control is like a lock on the room's entrance door. Method security is like a lock on each individual drawer inside — even after someone enters the room, each open drawer separately checks "is this drawer yours?". @PreAuthorize is that drawer lock.
@EnableMethodSecurity activates SpEL-based (Spring Expression Language) annotations:
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public void purge() { /* ... */ }
// Access the method argument and the principal via SpEL
@PreAuthorize("#ownerId == authentication.name or hasRole('ADMIN')")
public List<Doc> listFor(String ownerId) { /* ... */ }
// @PostAuthorize filters the RETURNED object after execution
@PostAuthorize("returnObject.owner == authentication.name")
public Doc get(Long id) { /* ... */ }
// @PreFilter / @PostFilter filter collection elements in/out
@PostFilter("filterObject.visibility == 'PUBLIC' or filterObject.owner == authentication.name")
public List<Doc> search(String q) { /* ... */ }
}
See how powerful this is: #ownerId refers to the method argument and authentication.name to the logged-in user — so you can say "only if you own the document, or you're an admin." returnObject refers to the return value and filterObject to each element of the list.
Method security uses Spring AOP proxies: Spring wraps a shell around your bean and puts the security check there. But when you call another method inside the same bean via this.purge(), the call does not go through the proxy and the annotation is ignored. Fix: move the guarded method to another bean, or restructure so it's entered from outside the bean.
Two more gotchas that separate seniors from juniors:
@PostAuthorizeruns after the method executes — any side effects already happened (rows deleted, emails sent). Never use it to guard a mutating method; only to filter the output of read-only methods.- Prefer
@EnableMethodSecurityover the deprecated@Secured/JSR-250 unless you need those specifically;@PreAuthorizewith SpEL is strictly more expressive.
CSRF — when it actually matters
CSRF is one of those topics everyone has heard of but few know exactly when it matters.
Imagine your bank honors anyone who has "your stamp" on a form, and your session cookie is that stamp, which the browser automatically presses onto every letter to the bank. Now a malicious site crafts a forged letter "give me $1000" and sends it from your browser — and the browser dutifully stamps it. The bank honors it! That's CSRF: abusing a credential the browser attaches automatically.
CSRF (Cross-Site Request Forgery) exploits ambient credentials the browser sends automatically — chiefly cookies. If your app authenticates via a session cookie or any auto-sent cookie, a malicious site can trigger a state-changing request from the victim's browser, and the cookie rides along.
- Cookie/session-based auth → CSRF protection ON. Spring's default
CsrfFilterrequires a token onPOST/PUT/PATCH/DELETE. This is why disabling CSRF on a form-login app breaks logins/forms. - Stateless token in
Authorizationheader → CSRF not applicable, disable it. The browser does not automatically attach anAuthorization: Bearerheader, so an attacker's forged request can't include the token. Hence the ubiquitous.csrf(c -> c.disable())on JWT APIs — correct there, dangerous elsewhere.
The nuance that catches many: if your "stateless" API stores the JWT in a cookie, CSRF is back on the table — now the browser auto-sends it. Use SameSite cookies and CSRF tokens.
Spring 6 uses a CsrfTokenRequestAttributeHandler and (for SPAs) the CookieCsrfTokenRepository.withHttpOnlyFalse() pattern where JS reads the token from a cookie and echoes it in a header.
CORS — a browser policy, not a security control
This is perhaps misunderstood more than any other topic. Let me be blunt up front: CORS does not make you secure.
CORS is like a doorman who stands only inside the browser and decides whether one site's JavaScript may read another site's response. But that doorman isn't on your server at all! A tool like curl or another server ignores this doorman completely and reaches your data directly. So CORS is not a security lock; it's just a browser policy.
CORS (Cross-Origin Resource Sharing) is enforced by the browser, relaxing the Same-Origin Policy for reads. It is not authentication or authorization — it only governs whether browser JS may read a cross-origin response. Configure it in Spring so preflight (OPTIONS) requests succeed (preflight is the trial OPTIONS request the browser sends before the real one to ask "am I allowed?"):
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults()); // picks up the CorsConfigurationSource bean below
return http.build();
}
@Bean
CorsConfigurationSource corsSource() {
var cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.example.com")); // never "*" with credentials
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setAllowCredentials(true);
var src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/**", cfg);
return src;
}
Note ordering: enable CORS in Spring Security (http.cors(...)) so the CorsFilter runs before authentication and doesn't reject preflight OPTIONS requests (which carry no credentials). If you get this ordering wrong, the preflight hits the auth filter, gets a 401, and the browser never sends the real request at all.
Common pitfalls & best practices
Always assume any unspecified door should be closed. End authorizeHttpRequests with .anyRequest().authenticated() (or .denyAll()). If tomorrow you add a new endpoint and forget to write a rule for it, it's better that it accidentally stays closed than accidentally open.
- Deny by default. A forgotten endpoint should fail closed.
permitAll()doesn't strip authentication — it allows the request; theSecurityContextmay still be populated. AndpermitAll()on a path won't help if a later, more specific matcher isn't reached — rules are evaluated top-to-bottom, first match wins. Order specific rules before general ones.- Don't leak whether a username exists — return the same error for bad user and bad password.
UsernameNotFoundExceptionis mapped toBadCredentialsExceptionby default (hideUserNotFoundExceptions=true); keep it that way. - Rotate signing keys and support key IDs (
kid) so you can roll RSA keys without downtime (JWKS makes this automatic for resource servers). - Prefer resource-server + external IdP over building your own auth server unless you have a strong reason.
SecurityContextHolderis ThreadLocal.
SecurityContextHolder is MODE_THREADLOCAL by default — meaning the identity is bound to the very thread that received the request and does not propagate to @Async/child threads or reactive pipelines. Inside an @Async method, SecurityContextHolder.getContext().getAuthentication() returns null and your code mysteriously breaks. Fix: use DelegatingSecurityContextExecutor or set MODE_INHERITABLETHREADLOCAL, and in WebFlux use the reactive ReactiveSecurityContextHolder.
Interview Questions
FilterChainProxy selects the matching SecurityFilterChain. SecurityContextHolderFilter finds no session context. BearerTokenAuthenticationFilter extracts the token, hands it to JwtAuthenticationProvider which uses JwtDecoder to verify signature + exp/nbf/iss, converts claims to authorities via JwtAuthenticationConverter, and stores the Authentication in the context. Finally AuthorizationFilter evaluates authorizeHttpRequests/@PreAuthorize. On failure, ExceptionTranslationFilter returns 401 (unauthenticated) or 403 (authenticated but forbidden).
hasRole("ADMIN") checks for the authority ROLE_ADMIN (auto-prefixed). hasAuthority("ADMIN") checks for literally ADMIN. If you store authorities as ADMIN (no prefix) and guard with hasRole, you get 403. Keep your UserDetails/JWT authorities and your rules consistent about the ROLE_ prefix.
A stateless JWT is valid until exp regardless of server state — you can't revoke it without adding server-side state (a denylist), which defeats statelessness. So keep access tokens to minutes and rely on a revocable, server-stored refresh token. "Logout" deletes the refresh token server-side and drops the access token client-side; the access token still technically works until it expires, which is why the window must be small.
An attacker takes an RS256 token, changes the header to HS256, and signs it using your public RSA key as the HMAC secret. If the server picks the algorithm from the token header, it verifies successfully. Prevention: pin the expected algorithm(s) in the decoder; never let the incoming token dictate the verification algorithm. Nimbus/Spring resource server does this correctly by default when configured with a specific key type.
Disable it for stateless APIs authenticated via the Authorization header — the browser doesn't auto-attach that header, so CSRF is impossible. Keep it enabled for any cookie/session-based auth, including a JWT stored in a cookie, because the browser auto-sends cookies and a cross-site request would carry them.
No. CORS is a browser relaxation of the Same-Origin Policy governing whether cross-origin JS may read a response. It doesn't authenticate or authorize anyone; a non-browser client (curl, server) ignores it entirely. Never rely on CORS to protect data — enforce authorization server-side.
@Service
class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { /* ... */ }
public void cleanup() { deleteAll(); } // called from a permitAll endpoint
}
cleanup() calls deleteAll() via this (self-invocation), bypassing the AOP proxy, so @PreAuthorize never runs. Any user reaching cleanup() deletes all orders. Fix: move deleteAll to another bean, inject and call it through the proxy, or restructure so the guarded method is entered from outside the bean.
User.withUsername("kai").password("secret").roles("USER").build();
It throws at authentication time (or startup with {noop} checks) because the password isn't encoded — with a DelegatingPasswordEncoder, secret has no {id} prefix, so matches fails with "There is no PasswordEncoder mapped for the id 'null'". You must encode it ({bcrypt}$2a$...) or explicitly prefix {noop} for a literal (dev only). Note .roles("USER") auto-adds the ROLE_ prefix, yielding authority ROLE_USER.
@PreAuthorize evaluates before the method — use it to gate execution. @PostAuthorize evaluates after, with access to returnObject — use it to filter what's returned based on the result. It's dangerous on any method with side effects: the body already executed (rows deleted, emails sent) before authorization fails, so it must only guard read-only methods.
Authorization Server issues tokens (Keycloak/Auth0, or Spring Authorization Server). Resource Server is your API validating incoming Bearer tokens and enforcing scopes. Client is an app that obtains tokens on a user's behalf (Authorization Code + PKCE) to call resource servers.
Scopes describe what the token/client is allowed to do (coarse, consent-driven, from the authorization server): SCOPE_orders:read. Roles describe who the user is in your domain: ROLE_ADMIN. A token can carry both; enforce scopes at the API boundary and finer roles/permissions in method security. Don't overload one to mean the other.
authorizeHttpRequests matches top-down, first-match-wins. anyRequest() matches everything, so any rule after it is dead code. Put specific matchers first and the catch-all (anyRequest().authenticated()/denyAll()) last.
BCrypt embeds a random 128-bit salt inside the output string ($2a$<cost>$<salt><hash>), so no separate salt column is needed; matches re-derives from it. Gotcha: BCrypt only hashes the first 72 bytes of input, silently ignoring the rest — long passphrases can collide on their prefix. For long inputs, pre-hash (SHA-256 → base64) or use Argon2/SCrypt.
Define two SecurityFilterChain beans, each ordered with @Order and scoped with securityMatcher("/api/**"). The first-matching chain wins; the /api chain is stateless (JWT, CSRF off), the UI chain is stateful (session, CSRF on, form login). Only the matched chain's filters run.
The default strategy is MODE_THREADLOCAL, so the SecurityContext is bound to the request thread and is not visible in @Async executor threads — SecurityContextHolder.getContext().getAuthentication() returns null there. Fix with DelegatingSecurityContextExecutor/DelegatingSecurityContextAsyncTaskExecutor, or set MODE_INHERITABLETHREADLOCAL (propagates to child threads but not pooled ones — the executor wrapper is the robust option). In WebFlux, the context lives in the Reactor Context, accessed via ReactiveSecurityContextHolder.
Spring Security isn't magic — it's a filter chain where authentication filters (who are you?) run before AuthorizationFilter (are you allowed?). Authentication puts identity in the SecurityContextHolder (a ThreadLocal); authorization weighs it against rules — and remember hasRole("X") means authority ROLE_X. In Spring Security 6 there's no more Adapter; declare beans, use authorizeHttpRequests/requestMatchers/@EnableMethodSecurity, and finish with deny-by-default. Hash passwords with BCrypt (embedded salt, 72-byte trap). For stateless APIs, use JWT (signed ≠ encrypted) with a short-lived access token plus a revocable server-side refresh token, and prefer configuring as a Resource Server. Don't conflate the three OAuth2/OIDC roles (Authorization Server / Resource Server / Client), and don't confuse scope with role. Method security is powerful but bypassed by self-invocation, and @PostAuthorize is dangerous on mutating methods. CSRF matters only with auto-sent cookie credentials; CORS is not security at all, just a browser policy. Master these and you'll both build secure systems and ace the senior questions.