Microservices (Java/Spring) · میکروسرویس سنیورSenior ~49 دقیقه مطالعه~42 min read
امنیتِ میکروسرویس: OAuth2/OIDC، JWT و RBAC/ABACMicroservice Security: OAuth2/OIDC, JWT & RBAC/ABAC
از احراز هویت تا اجازهدهی در میکروسرویسها: OAuth2/OIDC و جریانهای واقعی، ساختار و اعتبارسنجی JWT، انتشار توکن و mTLS، و انتخاب آگاهانه بین RBAC/ABAC/ReBAC با کد واقعی Spring و موتورهای سیاست.From authentication to authorization in microservices: OAuth2/OIDC real-world flows, JWT structure and validation, token propagation and mTLS, plus choosing wisely between RBAC/ABAC/ReBAC with real Spring code and policy engines.
پیشنیاز:Prerequisites: میکروسرویس: مبانی، مرزبندی و کِی/چراMicroservices: Foundations, Boundaries & When/Why
وقتی یک مونولیت را میشکنی به دهها میکروسرویس، امنیت هم میشکند. در مونولیت یک HttpSession داشتی، یک دیوارِ ورودی، و بعد از آن همه چیز «داخل خانه» بود و به هم اعتماد داشت. در دنیای میکروسرویس این فرضِ «داخلِ امن» میمیرد: هر سرویس یک فرآیندِ جدا روی یک شبکه است که هر بستهاش میتواند شنود یا جعل شود. این فصل نقشهی کاملِ امنیتِ سرویسبهسرویس است — از اینکه «تو کی هستی؟» تا «تو اجازهی این کار را داری؟» — با کدِ واقعیِ Spring و موتورهای سیاستِ امروزی.
هدفِ ما رسیدن از صفر به قضاوتِ سنیور است: نه فقط اینکه یک فیلترِ resource server را روشن کنی، بلکه بدانی چرا alg=none یک حفرهی مرگبار است، چرا JWT را نمیشود واقعاً باطل کرد، کِی سراغِ ABAC بروی و کِی ReBAC، و چطور یک راز (secret) را طوری نگه داری که فردا در یک نشتِ Git سرِ تیم را به باد ندهد.
- Authentication در برابر Authorization: تفاوتِ بنیادی و چرا قاطیکردنشان فاجعه است.
- OAuth2 و OIDC: نقشها، جریانِ Authorization Code + PKCE و Client Credentials (با دیاگرام).
- JWT: ساختار (header/payload/signature)، اعتبارسنجی، انقضا و چرخش کلید، و دامهای واقعی (
alg=none، نشت، باطلسازی). - انتشار توکن: resource server در Spring، token-relay در gateway، احرازِ لبه.
- mTLS و هویتِ service mesh: SPIFFE/SPIRE و اعتماد صفر (zero trust).
- RBAC در برابر ABAC در برابر ReBAC: مدلها، تعادلها، method security در Spring، و موتورهای سیاست (OPA/Rego، Cedar، Zanzibar/OpenFGA).
- مدیریت رازها: Vault، secretهای مهرومومشده و الگوهای production.
کدها و پیشفرضهای این فصل با Spring Boot 3.5.x و Spring Security 6.5.x (پاییز ۲۰۲۵) همخواناند. برای مشخصاتِ پروتکل به OAuth 2.1 (پیشنویسِ تثبیتشده که PKCE را اجباری و implicit/password grant را حذف کرده) و OpenID Connect Core 1.0 ارجاع میدهم.
۱) احراز هویت در برابر اجازهدهی — دو سؤالِ کاملاً جدا
تصور کن به فرودگاه میروی. سرِ گیتِ اول، مأمور پاسپورتت را چک میکند: «واقعاً تو همانی هستی که ادعا میکنی؟» این Authentication (احراز هویت، بهاختصار authn) است. بعد جلوتر، مأمورِ دیگری بلیت و کارتِ پروازت را میبیند: «تو حق داری سوارِ این پرواز، در این صندلی، در بخشِ بیزینس شوی؟» این Authorization (اجازهدهی، authz) است. پاسپورتِ معتبر داشتن به این معنی نیست که میتوانی هر جایی بنشینی.
این تفکیک قلبِ همهچیز است. Authentication به «تو کی هستی؟» جواب میدهد و هویت را ثابت میکند (identity). Authorization به «اجازهی چه کاری داری؟» جواب میدهد و روی آن هویت، مجوز اعمال میکند (permission). این دو در دو لایهی متفاوت اتفاق میافتند، و بزرگترین اشتباهِ تیمهای تازهکار این است که آنها را در یک نقطه قاطی میکنند.
یک اشتباهِ رایج در production: تیم تصور میکند «چون کاربر لاگین کرده و توکنِ معتبر دارد، پس مجاز است.» نه! یک توکنِ کاملاً معتبر که ثابت میکند کاربر alice است، هیچ چیزی دربارهی اینکه alice حق دارد فاکتورِ کاربرِ دیگری را ببیند نمیگوید. این دقیقاً منشأ آسیبپذیریِ IDOR و Broken Object Level Authorization است که سالهاست صدرنشینِ لیستِ OWASP API Security است. authn معتبر بودن، هرگز بهمعنای authz داشتن نیست.
Authentication ثابت میکند «تو کی هستی» (تأییدِ هویت)، و Authorization تعیین میکند «اجازهی چه کاری داری» (تأییدِ دسترسی). authn همیشه قبل از authz اتفاق میافتد، اما authn موفق هرگز authz را تضمین نمیکند. در HTTP، شکستِ authn معمولاً 401 Unauthorized (نامِ بد؛ در واقع یعنی «unauthenticated») و شکستِ authz 403 Forbidden است. نکتهی سنیوری: در یک معماریِ زیرو-تراست، authz باید در نزدیکترین نقطه به منبع (هر سرویس، حتی هر متد) دوباره اعمال شود، نه فقط در gateway.
۲) OAuth2 و OIDC — چارچوبِ تفویضِ دسترسی
قبل از هر کد، باید بدانی OAuth2 اصلاً یک پروتکلِ احراز هویت نیست؛ یک چارچوبِ تفویضِ اجازهدهی (delegated authorization) است. یعنی به یک اپلیکیشن اجازه میدهی از طرفِ تو به منبعی دسترسی داشته باشد، بدون اینکه رمزت را به آن بدهی.
وقتی ماشینت را به valetِ هتل میسپاری، کلیدِ اصلی را نمیدهی؛ یک کلیدِ محدود (valet key) میدهی که فقط در را باز میکند و ماشین را چند متر جابهجا میکند، اما صندوقِ عقب و داشبورد قفل میماند. توکنِ OAuth2 دقیقاً همان valet key است: دسترسیِ محدود و قابلِابطال بهجای رمزِ کاملت. سرویس هرگز رمزِ اصلیات را نمیبیند.
نقشها (roles) در OAuth2
- Resource Owner: صاحبِ داده — معمولاً کاربرِ انسانی.
- Client: اپلیکیشنی که میخواهد از طرفِ کاربر به منبع دسترسی داشته باشد (مثلاً یک SPA یا سرویسِ دیگر).
- Authorization Server (AS): هویت را چک و توکن صادر میکند (Keycloak، Auth0، Okta، Spring Authorization Server).
- Resource Server (RS): APIای که داده را نگه میدارد و توکن را اعتبارسنجی میکند (میکروسرویسِ تو).
OAuth2 بهتنهایی فقط access token میدهد که یعنی «این کلاینت اجازهی این scopeها را دارد». اما نمیگوید کاربر کیست. OpenID Connect (OIDC) یک لایهی نازک روی OAuth2 است که یک ID Token (همیشه یک JWT) اضافه میکند حاوی claimهای هویتی (sub, email, name) و یک endpointِ استاندارد /userinfo. قانونِ طلایی: برای authz از access token استفاده کن، برای دانستنِ هویتِ کاربر از ID token. ID token را هرگز بهعنوان مجوزِ دسترسی به API نفرست.
جریانِ Authorization Code + PKCE
این جریانِ استانداردِ امروز برای اپلیکیشنهای کاربرمحور (وب، موبایل، SPA) است. PKCE (مخففِ Proof Key for Code Exchange، تلفظ «پیکسی») یک لایهی محافظ اضافه میکند که حملهی «دزدیدنِ authorization code» را خنثی میکند. در OAuth 2.1، PKCE برای همهی کلاینتها اجباری شده — نه فقط موبایل.
مکانیزم ساده است: کلاینت یک رشتهی تصادفی به نامِ code_verifier میسازد، SHA-256 آن را (code_challenge) در درخواستِ اول میفرستد، و در انتها هنگامِ تبادلِ code، خودِ code_verifier را میفرستد. سرور هش را دوباره حساب میکند و تطبیق میدهد. حتی اگر مهاجم code را بدزدد، بدونِ verifier بیمصرف است.
دیاگرامِ ترتیبی از جریانِ Authorization Code + PKCE / Authorization Code flow with PKCE:
sequenceDiagram
participant U as User (Browser)
participant C as Client App
participant AS as Authorization Server
participant RS as Resource Server (API)
C->>C: create code_verifier + code_challenge (S256)
U->>C: click "Login"
C->>AS: /authorize?code_challenge=...&scope=openid orders.read
AS->>U: login + consent page
U->>AS: authenticate (password / MFA)
AS->>C: redirect back with authorization code
C->>AS: /token (code + code_verifier)
AS->>AS: verify hash(code_verifier) == code_challenge
AS->>C: access_token (+ id_token + refresh_token)
C->>RS: GET /orders (Authorization: Bearer access_token)
RS->>RS: validate JWT signature, iss, aud, exp
RS->>C: 200 OK (data)
اگر در سالِ ۲۰۲۶ کسی به تو گفت «برای SPA از implicit flow استفاده کن»، بدان که دانشش قدیمی است. implicit grant (که access token را مستقیم در URL برمیگرداند) بهخاطرِ نشتِ توکن در تاریخچهی مرورگر و logها حذف شده. همینطور Resource Owner Password Credentials (ROPC) که در آن اپلیکیشن رمزِ کاربر را مستقیم میگیرد — این کلِ فلسفهی OAuth را نقض میکند و در OAuth 2.1 حذف شده. جوابِ درست برای SPA و موبایل: Authorization Code + PKCE، تمام.
جریانِ Client Credentials — برای ماشینبهماشین
وقتی هیچ کاربری در کار نیست — مثلاً سرویسِ inventory میخواهد به سرویسِ pricing زنگ بزند — کاربری نیست که لاگین کند. اینجا Client Credentials grant استفاده میشود: خودِ سرویس با client_id و client_secret (یا بهتر، یک private_key_jwt) توکن میگیرد.
// یک ServiceClient که با client_credentials توکن میگیرد و به سرویسِ دیگر میزند
// Spring Security 6.5 — OAuth2 Client (servlet)
@Configuration
public class M2mClientConfig {
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clients,
OAuth2AuthorizedClientRepository authorizedClients) {
OAuth2AuthorizedClientProvider provider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials() // grant type = client_credentials
.build();
DefaultOAuth2AuthorizedClientManager manager =
new DefaultOAuth2AuthorizedClientManager(clients, authorizedClients);
manager.setAuthorizedClientProvider(provider);
return manager;
}
}
spring:
security:
oauth2:
client:
registration:
pricing-client:
provider: keycloak
client-id: inventory-svc
client-secret: ${INVENTORY_CLIENT_SECRET} # از Vault، نه از فایل!
authorization-grant-type: client_credentials
scope: pricing.read
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/prod
یک اشتباهِ رایج این است که به هر سرویس یک client با scopeهای گسترده میدهند («فقط بگذار کار کند»). سنیورها اصلِ least privilege را جدی میگیرند: هر client فقط scopeهایی را میگیرد که واقعاً لازم دارد. اگر سرویسِ inventory فقط قیمت میخواند، pricing.read بگیرد نه pricing.write. وقتی یک سرویس نشت کند — و یک روز نشت میکند — این تفاوتِ بینِ یک حادثهی کوچک و فاجعه است.
چون کلاینتهای public (SPA، موبایل) نمیتوانند secret را امن نگه دارند — کدشان روی دستگاهِ کاربر است و هر کسی میتواند آن را استخراج کند. بدونِ PKCE، اگر مهاجم authorization code را در مسیرِ redirect بدزدد (مثلاً از طریقِ یک اپِ مخربِ دیگر که همان URI scheme را ثبت کرده)، میتواند آن را با توکن تعویض کند. PKCE این را میبندد: code بدونِ code_verifier — که فقط در حافظهی همان کلاینتِ اصلی است — بیارزش است. در OAuth 2.1 حتی برای کلاینتهای confidential هم اجباری شد، چون دفاع در عمق (defense in depth) هزینهای ندارد.
۳) JWT — ساختار، اعتبارسنجی و دامها
JWT (مخففِ JSON Web Token، تلفظ «جات») یک توکنِ خودحامل (self-contained) است: همهچیزِ لازم برای اعتبارسنجی داخلِ خودش است، بدونِ نیاز به پرسوجوی دیتابیس در هر درخواست. این همان چیزی است که JWT را برای میکروسرویسهای بیحالت (stateless) جذاب میکند.
یک JWT مثلِ بلیتِ کنسرت با مهرِ هولوگرام است. روی بلیت نوشته «صندلی B12، معتبر تا ۱۰ شب» (این payload است، بهراحتی خواندنی)، و یک هولوگرامِ خاص دارد که جعلش سخت است (این signature است). مأمورِ درِ سالن لازم نیست به دفترِ مرکزی زنگ بزند تا اعتبارت را چک کند — فقط هولوگرام را نگاه میکند. اما دقت کن: چون روی بلیت رمزگذاریشده نیست، هر کسی میتواند «صندلی B12» را بخواند. JWT محرمانگی نمیدهد، فقط یکپارچگی (integrity).
ساختار: سه بخش با نقطه جدا شده
یک JWT سه بخشِ Base64URL است که با . جدا شدهاند: header.payload.signature.
eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0 ← Header {"alg":"RS256","kid":"k1"}
.eyJzdWIiOiJhbGljZSIsImlzcyI6Imh0dHBz... ← Payload {"sub":"alice","iss":"...","exp":...}
.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-R... ← Signature
- Header: الگوریتمِ امضا (
alg) و شناسهی کلید (kid). - Payload: claimها. استانداردها:
iss(صادرکننده)،sub(موضوع/کاربر)،aud(مخاطب)،exp(زمانِ انقضا)،iat(زمانِ صدور)،nbf(پیش از این معتبر نیست)،jti(شناسهی یکتای توکن). - Signature: امضای رمزنگاریشدهی دو بخشِ اول.
چون payload فقط Base64 است (نه رمزنگاری)، هر کسی که توکن را دارد میتواند محتوایش را بخواند. من بارها دیدهام تیمها شمارهی ملی، ایمیل، یا حتی flagهای داخلی را در JWT گذاشتهاند. این نشتِ اطلاعات است. اگر محرمانگی میخواهی، سراغِ JWE (JSON Web Encryption) برو یا اصلاً دادهی حساس را در توکن نگذار. JWT یکپارچگی میدهد، نه محرمانگی.
اعتبارسنجی در Spring — resource server
در Spring، تبدیلِ یک میکروسرویس به resource server تقریباً بیدردسر است. dependency را اضافه میکنی و issuer-uri را میدهی؛ Spring خودش کلیدهای عمومی را از endpointِ JWKS (JSON Web Key Set) صادرکننده میگیرد.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/prod
# Spring بهصورت خودکار JWKS را از {issuer}/.well-known/openid-configuration کشف میکند
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize را فعال میکند (prePostEnabled از پیشفرض true است)
public class ResourceServerConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
// میکروسرویسِ stateless: نه session، نه CSRF بر پایهی session
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
اما پیشفرضها کافی نیستند. Spring بهطور پیشفرض exp، nbf و iss را چک میکند، اما aud (audience) را چک نمیکند مگر خودت اضافه کنی. این یک دامِ امنیتیِ واقعی است: اگر audience را چک نکنی، توکنی که برای سرویسِ A صادر شده روی سرویسِ B هم میپذیرد (token substitution).
// یک validator سفارشی برای audience اضافه میکنیم
@Bean
JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("orders-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
return decoder;
}
نگاشتِ نقشها هم دستِ توست. Keycloak نقشها را در realm_access.roles میگذارد، نه در scope. باید یک converter بنویسی:
JwtAuthenticationConverter jwtAuthConverter() {
JwtAuthenticationConverter conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(jwt -> {
// scopeها را برمیداریم (پیشفرضِ Spring: SCOPE_)
var scopes = new JwtGrantedAuthoritiesConverter().convert(jwt);
// بهعلاوهی realm rolesِ Keycloak با پیشوندِ ROLE_
Map<String, Object> realm = jwt.getClaim("realm_access");
if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
roles.forEach(r -> scopes.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
return scopes;
});
return conv;
}
اینجاست که خودحامل بودنِ JWT میدرخشد. AS با کلیدِ خصوصیِ خود توکن را امضا میکند و کلیدِ عمومیِ متناظر را در یک endpointِ استانداردِ JWKS منتشر میکند. resource server یکبار (و بعد بهصورتِ کششده و با احترام به kid) این کلیدهای عمومی را میگیرد و از آن به بعد امضا را محلی و بدونِ رفتوبرگشتِ شبکهای وارسی میکند؛ فقط زمانی که یک kid ناشناخته ببیند JWKS را دوباره میکشد (پشتیبانی از چرخشِ کلید). به همین دلیل JWT برای میکروسرویسِ بیحالت ایدهآل است. آنتیتزِ این، token introspection (RFC 7662) است که در آن RS در هر درخواست از AS میپرسد «این توکن هنوز معتبر است؟» — باطلسازیِ فوری میدهد اما stateless بودن و کارایی را قربانی میکند. انتخابِ بین این دو یک بدهبستانِ کلاسیکِ سنیوری است.
معروفترین حملهی JWT: مهاجم header را به {"alg":"none"} تغییر میدهد، امضا را حذف میکند، و کتابخانههای بد آن را «معتبر» میپذیرند! چون گفته «الگوریتمی وجود ندارد پس امضا لازم نیست». حملهی دومِ خطرناک الگوریتم confusion است: توکنی که با RS256 (کلیدِ نامتقارن) امضا شده را به HS256 (کلیدِ متقارن) تغییر میدهند و کلیدِ عمومیِ سرور را بهعنوان secretِ HMAC استفاده میکنند — چون کلیدِ عمومی، عمومی است! دفاع: همیشه الگوریتمِ مجاز را در سمتِ سرور pin کن و بقیه را رد کن. کتابخانهی Nimbus که Spring استفاده میکند از JWKS الگوریتمِ درست را میگیرد و none را نمیپذیرد، اما اگر دستی JWT پارس میکنی، مراقب باش.
انقضا، چرخش و باطلسازی — سختترین بخش
خودحامل بودنِ JWT یک لبهی تیزِ دیگر دارد: باطلسازی (revocation) تقریباً غیرممکن است. وقتی توکن صادر شد، تا لحظهی exp معتبر است، حتی اگر کاربر را بلاک کنی یا رمزش را عوض کند. سرور دیتابیس را چک نمیکند؛ فقط امضا و exp را نگاه میکند.
راهحلِ استانداردِ صنعت:
- Access token کوتاهعمر (۵ تا ۱۵ دقیقه): چون کوتاه است، پنجرهی سوءاستفاده کوچک است.
- Refresh token بلندعمرِ قابلابطال: در دیتابیسِ AS ذخیره میشود و واقعاً میشود باطلش کرد. کلاینت با آن access tokenِ تازه میگیرد.
- Refresh token rotation: هر بار که refresh استفاده شد، یکی نو صادر و قبلی باطل میشود. اگر یک refresh دو بار استفاده شود (نشانهی سرقت)، کلِ خانواده را باطل کن.
اگر واقعاً به باطلسازیِ فوری نیاز داری (مثلاً «کاربر را همین الان قطع کن»)، سه گزینه داری:
- jti denylist:
jtiتوکنهای باطلشده را در Redis با TTL برابرِexpنگه دار. هر resource server قبل از پذیرش چک میکند. هزینه: یک lookup بهازای هر درخواست — دیگر کاملاً stateless نیستی. - token versioning: یک شمارهی نسخه در پروفایلِ کاربر نگه دار و در claim بگذار؛ هنگامِ بلاک، نسخه را افزایش بده.
- key rotation: در نشتِ گسترده، کلیدِ امضا را بچرخان تا همهی توکنهای قدیمی یکجا بمیرند.
با یک توکنِ خالصِ stateless نمیتوانی — و این نکتهی طلایی است که مصاحبهگر دنبالش است. باید حالتِ سروری اضافه کنی. سه الگو: (۱) denylist بر پایهی jti در یک کش مثلِ Redis با TTL؛ (۲) token version در دیتابیسِ کاربر که با claim مقایسه میشود؛ (۳) در بحران، چرخشِ کلیدِ امضا. اما بهترین دفاع پیشگیرانه است: access tokenهای کوتاهعمر (۵–۱۵ دقیقه) بهعلاوهی refresh token rotation. با این کار پنجرهی خطر آنقدر کوچک میشود که اغلبِ اوقات denylist لازم نمیشود. اگر بگویی «JWT را میشود راحت باطل کرد»، مصاحبهگر میفهمد ماهیتِ stateless را نفهمیدهای.
Access token کلیدِ کوتاهعمری است که در هر درخواست به resource server فرستاده میشود و بیحالت اعتبارسنجی میشود؛ چون زیاد در گردش است، عمداً کوتاهعمر است. Refresh token یک اعتبارِ بلندعمر و حساستر است که فقط به authorization server فرستاده میشود تا access tokenِ تازه بگیرد؛ در دیتابیسِ AS ذخیره و لذا قابلابطال است. Access token را هرگز جایی که refresh لازم است نفرست و برعکس. در مرورگر، refresh token را باید در یک کوکیِ HttpOnly, Secure, SameSite نگه داشت — نه در localStorage که در برابرِ XSS بیدفاع است.
۴) انتشار توکن و احرازِ لبه — از gateway تا عمقِ سرویسها
حالا سؤال این است: توکن از کجا وارد سیستم میشود و چطور بینِ سرویسها جابهجا میشود؟ الگوی رایج، احراز در لبه (edge) یعنی API gateway است، و بعد انتشارِ توکن (token propagation) به عمق.
توپولوژیِ احرازِ لبه و انتشارِ توکن / Edge authentication and token propagation topology:
flowchart LR
Client -->|Bearer JWT| GW[API Gateway]
GW -->|validate + TokenRelay| OrderSvc[Order Service]
OrderSvc -->|Bearer JWT propagated| PaymentSvc[Payment Service]
OrderSvc -->|Bearer JWT propagated| InventorySvc[Inventory Service]
GW -. JWKS .-> AS[(Authorization Server)]
OrderSvc -. JWKS .-> AS
PaymentSvc -. JWKS .-> AS
یک اشتباهِ معماریِ رایج: «gateway توکن را چک میکند، پس سرویسهای داخلی امناند و لازم نیست دوباره چک کنند.» این همان فرضِ «شبکهی داخلیِ امن» است که در زیرو-تراست مرده. اگر مهاجم به شبکهی داخلی نفوذ کند (SSRF، یک pod آلوده، misconfig)، مستقیم به سرویسها میزند و gateway را دور میزند. قانون: gateway کارِ سنگین را میکند (rate limit، فرمتِ توکن، احرازِ اولیه)، اما هر سرویس هم توکن را دوباره اعتبارسنجی و authz را اعمال میکند. لبه فیلترِ اول است، نه تنها فیلتر.
token relay در Spring Cloud Gateway
وقتی gateway خودش کاربر را با oauth2Login() احراز میکند (مثلاً برای یک وبِ سنتی)، فیلترِ TokenRelay بهطور خودکار access tokenِ کاربر را به سرویسِ downstream فوروارد میکند:
spring:
cloud:
gateway:
default-filters:
- TokenRelay= # access token را به همهی routeها فوروارد میکند
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
دو سبکِ انتشار وجود دارد و باید آگاهانه انتخاب کنی:
- Pass-through (relay): همان توکنِ کاربر را دستبهدست میکنی. ساده، اما
audتوکن باید همهی سرویسهای مسیر را بپذیرد و scope کاربر همهجا حاضر است — که اصلِ least privilege را میشکند. - Token Exchange (RFC 8693): هر سرویس توکنِ ورودی را به AS میدهد و یک توکنِ جدید و محدودتر برای گامِ بعد میگیرد. امنتر و قابلِردگیری، اما یک رفتوبرگشت به AS در هر پرش اضافه میکند. برای مسیرهای حساس (مثلاً پرداخت) ارزشش را دارد.
نه لزوماً — این یک بدهبستان است. Relayِ ساده (pass-through) کمهزینه و راحت است، اما یعنی یک توکن با scopeهای زیاد تا عمقِ سیستم سفر میکند و context کاربر همهجا فاش میشود؛ اگر یک سرویسِ عمقی نشت کند، مهاجم توکنی با دسترسیِ کاملِ کاربر در دست دارد. الگوی امنتر Token Exchange (RFC 8693) است: هر گام یک توکنِ محدودتر با aud و scopeِ دقیقاً همان گامِ بعد میگیرد، که least privilege و ردگیری را رعایت میکند اما latency و بار روی AS اضافه میکند. جوابِ سنیوری: برای مسیرهای عادی relay کن، برای مسیرهای پرریسک (پرداخت، حذفِ داده) token exchange یا حتی توکنِ سرویسمحورِ جدا.
mTLS و هویتِ service mesh
تا اینجا دربارهی احرازِ کاربر حرف زدیم. اما سرویسها به هم چطور اعتماد کنند؟ اینجا mTLS (Mutual TLS) وارد میشود: در TLS معمولی فقط سرور گواهی دارد، اما در mTLS هر دو طرف گواهی نشان میدهند. یعنی سرویسِ payment نهتنها رمزنگاریشده حرف میزند، بلکه رمزنگارانه ثابت میکند «من واقعاً paymentam».
TLS معمولی مثل این است که تو کارتِ شناساییِ فروشنده را میبینی اما او تو را نمیشناسد. mTLS مثلِ یک دستدادنِ مخفیِ باشگاهی است که هر دو نفر باید حرکتِ درست را انجام دهند وگرنه در بسته نمیشود. هیچکس نمیتواند خودش را جای عضوِ دیگر جا بزند، چون گواهیِ رمزنگاریشده لازم دارد.
مدیریتِ دستیِ گواهی برای صدها سرویس کابوس است. اینجا service mesh (مثلِ Istio یا Linkerd) و استانداردِ SPIFFE/SPIRE وارد میشوند:
- SPIFFE (Secure Production Identity Framework For Everyone): استانداردی که به هر workload یک هویتِ ساختاریافته میدهد به شکلِ یک URI مثلِ
spiffe://prod.example.com/ns/payments/sa/payment-svc. این هویت به IP یا hostname وابسته نیست، به خودِ workload. - SPIRE: پیادهسازیِ اجراییِ SPIFFE که این هویتها را بهصورتِ گواهیِ کوتاهعمرِ X.509 (به نامِ SVID، مخففِ SPIFFE Verifiable Identity Document) صادر و بهطورِ خودکار میچرخاند.
- Istio بهطورِ بومی هویتِ سازگار با SPIFFE میدهد: CA داخلیِ Istiod، گواهیِ X.509-SVID صادر و mTLS را بینِ همهی سرویسها بهصورتِ شفاف (بدونِ تغییرِ کدِ اپلیکیشن) برقرار میکند. overheadِ mTLS معمولاً فقط ۱ تا ۲ میلیثانیه است.
اشتباه نکن — mTLS و OAuth دو لایهی مکمل هستند، نه رقیب:
- mTLS / SPIFFE به سؤالِ «کدام سرویس با من حرف میزند؟» جواب میدهد (هویتِ workload، لایهی transport).
- OAuth2 / JWT به سؤالِ «کدام کاربر پشتِ این درخواست است و چه اجازهای دارد؟» جواب میدهد (هویتِ کاربر، لایهی application). یک معماریِ بالغ هر دو را دارد: mesh کانالِ سرویسبهسرویس را رمزنگاری و احراز میکند، و JWT contextِ کاربر را حمل میکند.
نه، مکملِ آن است و در لایهی متفاوتی کار میکند. mTLS هویتِ workload (کدام سرویس) را در لایهی transport ثابت میکند و کانال را رمزنگاری میکند — پاسخِ زیرو-تراست به «اعتماد به شبکهی داخلی». اما mTLS نمیداند کاربرِ نهایی کیست یا چه اجازهای دارد؛ آن اطلاعات در JWT است که در لایهی application حمل میشود. الگوی سنیوری: service mesh (Istio + SPIFFE/SPIRE) mTLS را شفاف و بدونِ کدِ اضافه برقرار میکند، و اپلیکیشن روی همان کانال JWT کاربر را برای authz اعمال میکند. حذفِ یکی بهبهانهی دیگری یک اشتباهِ امنیتیِ رایج است.
۵) RBAC در برابر ABAC در برابر ReBAC — قلبِ authorization
حالا رسیدیم به عمیقترین بخش: چطور تصمیم بگیریم که یک کاربر اجازهی یک کار را دارد؟ سه مدلِ بزرگ وجود دارد و انتخابِ اشتباه، سالها بدهیِ فنی میسازد.
RBAC — کنترلِ دسترسیِ نقشمحور
RBAC (Role-Based Access Control) سادهترین است: به کاربر نقش میدهی (ADMIN, EDITOR, VIEWER)، و به هر نقش مجوز میدهی. تصمیم این است: «آیا کاربر نقشی دارد که این عمل را اجازه میدهد؟»
RBAC مثلِ کارتِ کارمندی است. کارتت یک «سطح» دارد؛ کارتِ «مدیریت» درِ طبقهی پنجم را باز میکند، کارتِ «کارمندِ عادی» نمیکند. درها فقط سطح را چک میکنند، نه اینکه تو دقیقاً کی هستی. ساده، قابلِفهم، اما درشتدانه (coarse-grained): نمیتواند بگوید «فقط درِ اتاقِ خودت».
ABAC — کنترلِ دسترسیِ ویژگیمحور
ABAC (Attribute-Based Access Control) بر پایهی ویژگیها (attributes) تصمیم میگیرد: ویژگیِ کاربر (department=finance)، ویژگیِ منبع (owner=alice, classification=secret)، و ویژگیِ محیط (time, ip, device). تصمیم یک تابع از این ویژگیهاست: «اجازه بده اگر user.department == resource.department و ساعتِ کاری است.»
اگر RBAC یک درِ کارتی است، ABAC یک نگهبانِ باهوش است که قاعدهای در ذهن دارد: «هر کسی از بخشِ مالی، در ساعتِ کاری، از داخلِ ساختمان، میتواند واردِ بایگانیِ مالی شود.» تصمیم رویهوا از ترکیبِ چند ویژگی ساخته میشود، نه از یک برچسبِ ثابت. بسیار انعطافپذیر، اما استدلال دربارهی «چه کسی به چه چیزی دسترسی دارد؟» سختتر میشود.
ReBAC — کنترلِ دسترسیِ رابطهمحور
ReBAC (Relationship-Based Access Control) تصمیم را بر پایهی گرافِ روابط میگیرد: «alice میتواند این سند را ویرایش کند، چون editorِ پوشهای است که سند در آن قرار دارد.» این مدلی است که Google Zanzibar معرفی کرد و پشتِ Drive، Calendar و YouTube را میراند. داده بهشکلِ relationship tuple ذخیره میشود: document:budget#editor@user:alice.
ReBAC مثلِ این است که بگویی «تو میتوانی به این آلبومِ عکس دسترسی داشته باشی چون دوستِ صاحبِ آلبومی» یا «چون عضوِ گروهی هستی که آلبوم با آن بهاشتراک گذاشته شده.» دسترسی از مسیری در گرافِ روابط میآید، نه از یک نقش یا ویژگیِ ثابت. این دقیقاً همان چیزی است که برای «share»های تودرتوی محصولاتِ SaaS لازم داری.
جدولِ مقایسه:
| بُعد | RBAC | ABAC | ReBAC |
|---|---|---|---|
| مبنای تصمیم | نقشها | ویژگیها (کاربر/منبع/محیط) | روابط در گراف |
| دانهبندی | درشت | ریز، پویا | ریز، در سطحِ شیء |
| نمونه سؤال | «admin است؟» | «همان بخش و ساعتِ کاری؟» | «editorِ این پوشه است؟» |
| نقطهی قوت | سادگی، ممیزی آسان | انعطاف، context-aware | «share»های تودرتو، مالکیت |
| نقطهی ضعف | انفجارِ نقش (role explosion) | استدلال و ممیزی سخت | زیرساختِ اختصاصی لازم |
| مثال ابزار | Spring hasRole |
OPA/Rego، Cedar | Zanzibar، OpenFGA، SpiceDB |
| کِی؟ | نقشهای کم و ثابت | قواعدِ context-محور | مالکیت/اشتراکِ سلسلهمراتبی |
RBAC خالص در مقیاس میترکد. وقتی نیاز به دانهبندیِ ریز پیدا میکنی، نقشها زیاد میشوند: EDITOR_REGION_EU, EDITOR_REGION_US, VIEWER_FINANCE_2024... من پروژههایی دیدهام که به هزاران نقش رسیدهاند و هیچکس نمیداند کدام نقش چه میکند. این نشانهی آن است که مسئلهات ذاتاً ویژگیمحور یا رابطهمحور است و باید سراغِ ABAC یا ReBAC بروی. اگر داری نقش را با داده کد میکنی (EDITOR_REGION_EU)، در واقع داری ABAC را بد پیادهسازی میکنی.
در عمل، سیستمهای بالغ هیبریداند و این خیلی طبیعی است. RBAC برای «چه کسی adminِ سیستم است» (درشت، پایدار)، بهعلاوهی ReBAC یا ABAC برای «چه کسی این سندِ خاص را میبیند» (ریز، پویا). GitHub نمونهی درسی است: نقشهای سازمانی (owner/member) + دسترسیِ رابطهمحورِ ریز به هر repo. اشتباه این است که فکر کنی باید یکی را انتخاب کنی. سؤالِ درست این است: «هر تصمیم را با کدام مدل بگیرم؟»
هر دو مجوز را نمایندگی میکنند اما از دو منظرِ متفاوت. Scope به کلاینت تعلق دارد و میگوید «این اپلیکیشن اجازه دارد از طرفِ کاربر چه کاری بکند» (مثلاً orders.read)؛ کاربر هنگامِ consent آن را تأیید میکند و در OAuth تعریف میشود. Role به کاربر تعلق دارد و میگوید «این شخص در سیستم چه جایگاهی دارد» (مثلاً ADMIN)؛ مفهومی از دامنهی اپلیکیشن است، نه خودِ OAuth. یک نکتهی ظریف: یک درخواستِ مجاز باید هر دو را داشته باشد — کلاینت scopeِ لازم را داشته باشد و کاربر نقش/رابطهی لازم را. در Spring، scopeها با پیشوندِ SCOPE_ و نقشها با ROLE_ به authority نگاشت میشوند، و میتوانی در @PreAuthorize هر دو را ترکیب کنی. اشتباهِ رایج: تکیه فقط به scope و فراموشکردنِ چکِ نقش/مالکیتِ کاربر (دوباره منشأ IDOR).
method security در Spring — @PreAuthorize
Spring Security از @EnableMethodSecurity پشتیبانی میکند (که از Spring Security 6 جایگزینِ @EnableGlobalMethodSecurity قدیمی شده و prePostEnabled پیشفرض true است). با @PreAuthorize میتوانی هم RBAC و هم ABAC ساده را با SpEL بنویسی:
@Service
public class DocumentService {
// RBAC ساده: فقط نقشِ ADMIN
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { /* ... */ }
// scope-based (از JWT)
@PreAuthorize("hasAuthority('SCOPE_documents.write')")
public Document create(DocumentDto dto) { /* ... */ }
// ABAC: کاربر باید صاحبِ سند باشد (ویژگیِ منبع را چک میکنیم)
@PreAuthorize("#doc.owner == authentication.name")
public Document update(Document doc) { /* ... */ }
// ترکیبِ نقش و ویژگی
@PreAuthorize("hasRole('EDITOR') and #doc.department == authentication.principal.claims['department']")
public Document edit(Document doc) { /* ... */ }
// @PostAuthorize: بعد از اجرا، خروجی را فیلتر کن (احتیاط: متد اجرا شده!)
@PostAuthorize("returnObject.owner == authentication.name")
public Document findById(Long id) { /* ... */ }
}
@PostAuthorize بعد از اجرای متد چک میکند. اگر متدت side-effect دارد (نوشتن در دیتابیس، ارسالِ event)، آن side-effect حتی اگر authz رد شود اتفاق میافتد — فقط خروجی به کاربر برنمیگردد. @PostAuthorize را فقط برای متدهای خواندنیِ بیside-effect استفاده کن. برای متدهای نویسنده، همیشه @PreAuthorize که قبل از اجرا تصمیم میگیرد.
یک آنتیپترنِ رایج: if گذاشتنِ چکِ دسترسی اینجا و آنجا در کدِ کنترلر و سرویس. بعد از چند ماه، هیچکس نمیداند یک endpoint دقیقاً چه قاعدهای دارد و یک if فراموششده = حفره. authz را در یک لایهی صریح و متمرکز نگه دار: یا @PreAuthorizeهای تمیز، یا بهتر، یک موتورِ سیاستِ بیرونی که در ادامه میگوییم. قاعده: «policy را از code جدا کن.»
موتورهای سیاست — وقتی SpEL کم میآورد
وقتی قواعدِ authz پیچیده، پرتغییر، یا مشترک بینِ چند سرویس (شاید چند زبان) میشوند، آنها را از کد بیرون بکش و به یک policy engine بسپار. این یعنی Policy as Code.
OPA (Open Policy Agent) یک موتورِ سیاستِ عمومیِ CNCF است که سیاستها را به زبانِ Rego مینویسی. معمولاً بهشکلِ sidecar کنارِ هر سرویس اجرا میشود؛ سرویس یک JSON از context میفرستد و OPA allow/deny برمیگرداند.
# policy: authz.rego — یک قاعدهی ABAC/ReBAC ساده
package httpapi.authz
default allow := false
# ادمینها همهچیز
allow if {
input.user.roles[_] == "admin"
}
# کاربر میتواند سندِ خودش را بخواند
allow if {
input.method == "GET"
input.resource.owner == input.user.id
}
# اعضای بخشِ مالی در ساعتِ کاری میتوانند گزارشهای مالی را ببینند (ABAC)
allow if {
input.resource.type == "finance_report"
input.user.department == "finance"
input.time.hour >= 8
input.time.hour < 18
}
تصور کن هر سرویس بهجای اینکه خودش قانون بلد باشد، یک مشاورِ حقوقیِ کنارِ دستش (sidecar) دارد. سرویس میپرسد «alice میتواند این را حذف کند؟»، پرونده (context JSON) را میدهد، و مشاور بر اساسِ کتابِ قانونِ مشترک (Rego policies) بله/خیر میگوید. مزیت: قانون یکجا نگهداری میشود، همهی سرویسها یکسان قضاوت میکنند، و تغییرِ قانون کدِ سرویس را دست نمیزند.
Cedar (از AWS، ۲۰۲۳) یک زبانِ سیاستِ تخصصیِ authorization است که RBAC، ABAC و ReBAC را با هم پشتیبانی میکند و روی قابلِتحلیل بودن (analyzability) و سرعت تمرکز دارد؛ بنچمارکهای AWS آن را چند ده برابر سریعتر از Rego در سناریوهای دسترسی گزارش کردهاند. برخلافِ Rego که عمومی است، Cedar عمداً فقط برای authz طراحی شده تا بتوان دربارهی سیاستها اثباتِ صوری کرد.
// یک سیاستِ Cedar — ReBAC + RBAC
permit(
principal,
action == Action::"editDocument",
resource
)
when {
resource.owner == principal ||
principal in resource.editors // رابطه: عضوِ editorهای منبع
};
Zanzibar / OpenFGA / SpiceDB برای ReBACِ خالص در مقیاسِ بزرگاند. مدل را با روابط تعریف میکنی و داده را بهشکلِ tuple ذخیره میکنی، بعد سؤالِ «check» میپرسی که گراف را میپیماید.
گرافِ روابطِ ReBAC به سبکِ Zanzibar / Zanzibar-style ReBAC relationship graph:
flowchart TD
U[user:alice] -->|member| G[group:engineering]
G -->|editor| F[folder:specs]
F -->|parent| D[document:api-design]
U -->|viewer| D2[document:roadmap]
Q{{"Check: can alice edit document:api-design?"}}
Q -.follows.-> U
Q -.->|yes: member→editor→parent| D
# مدلِ OpenFGA (نمایشِ مفهومی) — روابط و ارثبریِ آنها
# type document
# relations
# define parent: [folder]
# define editor: [user, group#member] or editor from parent
# define viewer: [user] or editor
به شکلِ تصمیم بستگی دارد. OPA/Rego (یا Cedar) وقتی عالی است که تصمیم تابعی از ویژگیها و contextِ همان درخواست باشد — نقش، بخش، ساعت، IP — یعنی ذاتاً ABAC. همهی دادهٔ لازم یا در توکن است یا در payload، و policy یک تابعِ خالص است. اما وقتی تصمیم به روابطِ تودرتو و گرافیِ داده وابسته است — «alice میتواند این سند را ببیند چون عضوِ گروهی است که editorِ پوشهی والدِ سند است» — این ReBAC است و به یک دیتابیسِ روابط مثلِ Zanzibar/OpenFGA/SpiceDB نیاز داری که آن گراف را ذخیره و پیمایش کند. تلاش برای مدلکردنِ گرافِ عمیقِ روابط در Rego، هم کند میشود هم دادهی روابط را باید یکجوری داخلِ OPA بریزی. جوابِ کوتاه: ویژگیمحور → OPA/Cedar؛ رابطهمحورِ گرافی → Zanzibar-family.
بدهبستانِ کلاسیک. درونخطی (@PreAuthorize, کدِ داخلِ سرویس): latencyِ صفر، ساده برای شروع، اما policy در چند سرویس و شاید چند زبان تکرار و از هم واگرا میشود و ممیزیِ مرکزی سخت است. بیرونی (OPA sidecar, PDP مرکزی): یک منبعِ حقیقتِ واحد برای policy، قابلِممیزی، مستقل از زبان، و تغییرِ قانون بدونِ redeployِ سرویس — اما یک hop و وابستگیِ عملیاتیِ جدید اضافه میکند. الگوی متعادلِ صنعتی: policyها را مرکزی بنویس و منتشر کن، اما تصمیم را محلی (sidecar کنارِ سرویس) بگیر تا نه نقطهی شکستِ مرکزی داشته باشی نه latencyِ شبکهای. این دقیقاً مدلِ توزیعِ bundleهای OPA است.
۶) مدلِ دادهٔ RBAC و denylist توکن — SQL دو-گویشه
بخشِ زیادی از RBAC در دیتابیسِ رابطهای مینشیند: کاربر، نقش، مجوز، و جدولِ واسط. چون هم Oracle و هم PostgreSQL در پروژهها رایجاند، تفاوتهای گویش را جدا نشان میدهم.
اسکیمای پایهی RBAC (یکسان در هر دو، با تفاوتِ نوعداده):
-- PostgreSQL
CREATE TABLE app_user (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE app_role (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE -- e.g. ADMIN, EDITOR
);
CREATE TABLE user_role (
user_id BIGINT NOT NULL REFERENCES app_user(id),
role_id BIGINT NOT NULL REFERENCES app_role(id),
PRIMARY KEY (user_id, role_id)
);
-- Oracle (19c/23ai)
CREATE TABLE app_user (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR2(100) NOT NULL UNIQUE,
enabled NUMBER(1) DEFAULT 1 NOT NULL, -- Oracle: BOOLEAN بومی تازه در 23ai آمد
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE TABLE app_role (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR2(64) NOT NULL UNIQUE
);
CREATE TABLE user_role (
user_id NUMBER NOT NULL REFERENCES app_user(id),
role_id NUMBER NOT NULL REFERENCES app_role(id),
PRIMARY KEY (user_id, role_id)
);
- BOOLEAN: PostgreSQL نوعِ بومیِ
BOOLEANدارد؛ Oracle تا پیش از 23ai نداشت و ازNUMBER(1)با0/1استفاده میشد (در SQL؛ در PL/SQL همیشهBOOLEANبود). - IDENTITY: هر دو
GENERATED ALWAYS AS IDENTITYرا پشتیبانی میکنند (Oracle از 12c)؛ در Oracleِ قدیمی الگویSEQUENCE + TRIGGERرایج بود. - رشتهی خالی = NULL: در Oracle رشتهی
''معادلِNULLاست! پسWHERE username = ''هیچوقت چیزی برنمیگرداند. در PostgreSQL''یک مقدارِ واقعی است. این تفاوت در چکِ دسترسی میتواند باگِ امنیتی بسازد. - VARCHAR2 در برابر VARCHAR: در Oracle همیشه
VARCHAR2(چون رفتارِVARCHARتضمینشده نیست)؛ در PostgreSQLVARCHAR.
پرسوجوی نقشهای یک کاربر با تجمیعِ رشتهای — اینجا گویش واقعاً فرق میکند:
-- PostgreSQL: STRING_AGG
SELECT u.username, STRING_AGG(r.name, ',' ORDER BY r.name) AS roles
FROM app_user u
JOIN user_role ur ON ur.user_id = u.id
JOIN app_role r ON r.id = ur.role_id
WHERE u.username = 'alice'
GROUP BY u.username;
-- Oracle: LISTAGG
SELECT u.username,
LISTAGG(r.name, ',') WITHIN GROUP (ORDER BY r.name) AS roles
FROM app_user u
JOIN user_role ur ON ur.user_id = u.id
JOIN app_role r ON r.id = ur.role_id
WHERE u.username = 'alice'
GROUP BY u.username;
جدولِ denylist برای jti (باطلسازیِ توکن) — با upsert که در دو گویش کاملاً فرق دارد:
-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO revoked_token (jti, revoked_at, expires_at)
VALUES ('a1b2c3', now(), now() + INTERVAL '15 minutes')
ON CONFLICT (jti) DO NOTHING;
-- Oracle: MERGE
MERGE INTO revoked_token t
USING (SELECT 'a1b2c3' AS jti FROM dual) s
ON (t.jti = s.jti)
WHEN NOT MATCHED THEN
INSERT (jti, revoked_at, expires_at)
VALUES ('a1b2c3', SYSTIMESTAMP, SYSTIMESTAMP + INTERVAL '15' MINUTE);
جدولِ SQL بالا برای فهم خوب است، اما در production یک jti denylist را در Redis با TTL (برابرِ زمانِ باقیمانده تا exp) بگذار، نه در دیتابیسِ رابطهای. دلیل: این lookup در هر درخواست به resource server اجرا میشود؛ باید میکروثانیهای و in-memory باشد و خودش پاک شود. یک کوئریِ SQL در هر درخواستِ API، سیستم را زانو میزند و جدول را با رکوردهای منقضی پر میکند. TTLِ Redis رکورد را دقیقاً وقتی توکن بههرحال منقضی میشود حذف میکند.
۷) مدیریتِ رازها — Vault، Sealed Secrets و الگوهای امن
توکنها، کلیدها، client secretها، رمزِ دیتابیس — همه رازند. بزرگترین اشتباهِ ابتداییِ امنیت، سختکدکردنِ راز در کد یا commit در Git است. یک راز که یکبار در تاریخچهی Git رفت، برای همیشه آنجاست (حتی اگر بعداً پاکش کنی).
یک راز در فایلِ config = یک راز در Git = یک راز روی لپتاپِ هر توسعهدهنده و در هر backup. راز در ENV variableِ Docker image هم بهتر نیست؛ با docker inspect یا در لاگِ crash بیرون میریزد. حتی Kubernetes Secret پیشفرض فقط Base64 است، نه رمزنگاری — هرکسی با دسترسیِ خواندن به namespace میتواند بازش کند. رازها به مدیریتِ اختصاصی نیاز دارند.
HashiCorp Vault استانداردِ عملیِ صنعت است. دو قابلیتِ کلیدی:
- رازهای ایستا (KV): رمزها را رمزنگاریشده ذخیره و با کنترلِ دسترسیِ دقیق و audit trail سرو میکند.
- رازهای پویا (dynamic secrets): این نکتهی طلایی است — Vault میتواند در لحظه یک اعتبارِ دیتابیس با عمرِ کوتاه بسازد که خودش منقضی میشود. اپلیکیشن هرگز رمزِ ثابتِ دیتابیس ندارد؛ هر بار یک رمزِ موقتِ چنددقیقهای میگیرد. اگر نشت کند، خودبهخود میمیرد.
// Spring Cloud Vault — رازها بهعنوان property source تزریق میشوند
// bootstrap/application.yml
spring:
cloud:
vault:
uri: https://vault.example.com:8200
authentication: KUBERNETES # با ServiceAccountِ pod احراز میشود، بدونِ رمزِ ثابت
kubernetes:
role: order-service
kv:
enabled: true
backend: secret
default-context: order-service
// بعد از تزریق، راز مثلِ هر property معمولی در دسترس است:
@Value("${db.password}") // از Vault آمده، نه از فایل
private String dbPassword;
در دنیای Kubernetes چند الگو رقابت میکنند:
| الگو | ایده | نقطهی قوت | نقطهی ضعف |
|---|---|---|---|
| Sealed Secrets (Bitnami) | رازِ رمزنگاریشدهی نامتقارن که امن در Git میرود؛ فقط controllerِ داخلِ cluster بازش میکند | GitOpsِ واقعی برای رازها | مدیریتِ مرکزی و چرخشِ خودکار ندارد |
| External Secrets Operator (ESO) | رازها را از Vault/AWS/GCP/Azure میکشد و به Kubernetes Secret همگام میکند | استانداردِ صنعتی، اتصال به vaultهای ابری | خودِ Kubernetes Secret همچنان Base64 است |
| Vault Secrets Operator / CSI | Vault مستقیم راز را به pod تزریق میکند | رازِ پویا و چرخشِ خودکار | پیچیدگیِ عملیاتیِ Vault |
اگر تازه شروع کردهای و GitOps میخواهی، Sealed Secrets ساده و کافی است: راز را رمزنگاری کن و با خیالِ راحت در Git بگذار. اما وقتی رشد کردی و به چرخشِ خودکار، رازِ پویا، و مدیریتِ مرکزیِ چند-cluster نیاز پیدا کردی، به External Secrets Operator با پشتِ Vault مهاجرت کن. جامعه در ۲۰۲۶ همین مسیر را توصیه میکند. نکتهی مهم: در هر دو حالت، هدفِ نهایی حذفِ رازِ ایستا از دستِ انسان است.
چون Kubernetes Secret پیشفرض راز را فقط Base64-encode میکند، نه رمزنگاری — هرکسی با دسترسیِ get secret در آن namespace، یا با دسترسی به etcd، میتواند بازش کند. سه ضعفِ اصلی: (۱) رمزنگارینشده at rest مگر خودت encryption-at-rest را در etcd روشن کنی؛ (۲) نه چرخشِ خودکار دارد نه انقضا — راز تا ابد ثابت است؛ (۳) audit trailِ دقیقِ «چه کسی چه رازی را کِی خواند» ندارد. راهحلهای production اینها را میبندند: Vault برای رازِ پویا و کوتاهعمر و auditِ کامل، ESO برای همگامسازی، و Sealed Secrets برای GitOpsِ امن. جوابِ سنیوری: راز نباید هرگز ایستا و در دستِ انسان بماند؛ باید کوتاهعمر، خودچرخان و قابلِممیزی باشد.
جمعبندی
- authn ≠ authz: احراز هویت «تو کی هستی» را ثابت میکند، اجازهدهی «چه اجازهای داری» را؛ authnِ معتبر هرگز بهمعنای authz نیست (منشأ IDOR).
- OAuth2/OIDC: OAuth2 چارچوبِ تفویضِ دسترسی است نه احراز هویت؛ OIDC لایهی هویت با ID token است. برای کاربر Authorization Code + PKCE، برای ماشین Client Credentials؛ implicit و password grant مردهاند.
- JWT: خودحامل، فقط یکپارچگی (نه محرمانگی).
iss/aud/expرا چک کن، الگوریتم را pin کن (alg=noneو RS/HS confusion را ببند)، و بدان که باطلسازی سخت است — access tokenِ کوتاه + refresh rotation، و در صورتِ نیازjtidenylist در Redis. - انتشار و لبه: احراز در gateway، اما authz را در هر سرویس دوباره اعمال کن (defense in depth)؛ token relay ساده است ولی برای مسیرهای حساس token exchange امنتر است.
- mTLS/SPIFFE: هویتِ workload در لایهی transport، مکملِ JWT که هویتِ کاربر را حمل میکند؛ service mesh آن را شفاف میکند.
- RBAC/ABAC/ReBAC: نقشمحور (ساده، role explosion)، ویژگیمحور (انعطاف، OPA/Cedar)، رابطهمحور (گراف، Zanzibar/OpenFGA)؛ سیستمهای واقعی هیبریدند.
@PreAuthorizeبرای شروع، policy engine وقتی قواعد پیچیده یا مشترک میشوند. - رازها: هرگز در Git/config/Base64؛ Vault (رازِ پویا)، ESO، و Sealed Secrets الگوهای امناند.
جملهای که با خودت ببر: امنیتِ میکروسرویس یعنی زیرو-تراست در همهی لایهها — نه اعتماد به شبکه، نه اعتماد به توکنِ صرفاً معتبر، نه اعتماد به رازِ ایستا. هر لایه هویت را ثابت و اجازه را دوباره اعمال میکند.
When you break a monolith into dozens of microservices, security breaks too. In the monolith you had one HttpSession, one front door, and behind it everything lived "inside the house" and trusted each other. In the microservice world that "trusted interior" assumption dies: each service is a separate process on a network where every packet can be sniffed or forged. This chapter is the full map of service-to-service security — from "who are you?" to "are you allowed to do this?" — with real Spring code and today's policy engines.
Our goal is to go from zero to senior judgment: not just flipping on a resource-server filter, but knowing why alg=none is a fatal hole, why a JWT can't truly be revoked, when to reach for ABAC and when for ReBAC, and how to hold a secret so that tomorrow's Git leak doesn't cost the team its head.
- Authentication vs Authorization: the fundamental difference and why conflating them is a disaster.
- OAuth2 and OIDC: roles, the Authorization Code + PKCE and Client Credentials flows (with a diagram).
- JWT: structure (header/payload/signature), validation, expiry and key rotation, and real pitfalls (
alg=none, leakage, revocation). - Token propagation: Spring resource server, gateway token-relay, edge authentication.
- mTLS and service-mesh identity: SPIFFE/SPIRE and zero trust.
- RBAC vs ABAC vs ReBAC: models, trade-offs, Spring method security, and policy engines (OPA/Rego, Cedar, Zanzibar/OpenFGA).
- Secrets management: Vault, sealed secrets, and production patterns.
The code and defaults here track Spring Boot 3.5.x and Spring Security 6.5.x (autumn 2025). For protocol specifics I reference OAuth 2.1 (the consolidated draft that makes PKCE mandatory and removes implicit/password grants) and OpenID Connect Core 1.0.
1) Authentication vs Authorization — two completely separate questions
Picture arriving at the airport. At the first gate an officer checks your passport: "are you really who you claim to be?" That is Authentication (authn for short). Further on, another officer checks your boarding pass and seat: "do you have the right to board this flight, in this seat, in business class?" That is Authorization (authz). Holding a valid passport does not mean you may sit anywhere you like.
That split is the heart of everything. Authentication answers "who are you?" and proves identity. Authorization answers "what are you allowed to do?" and applies permissions on top of that identity. The two happen at different layers, and the single biggest mistake junior teams make is fusing them at one point.
A common production mistake: the team assumes "since the user is logged in with a valid token, they're allowed." No! A perfectly valid token proving the user is alice says nothing about whether alice may read someone else's invoice. This is exactly the source of IDOR and Broken Object Level Authorization, which have topped the OWASP API Security list for years. Being validly authenticated is never the same as being authorized.
Authentication proves "who you are" (identity verification), and Authorization decides "what you may do" (access verification). authn always happens before authz, but a successful authn never guarantees authz. In HTTP, authn failure is usually 401 Unauthorized (a misnomer — it really means "unauthenticated") and authz failure is 403 Forbidden. Senior nuance: in a zero-trust architecture, authz must be re-enforced at the point closest to the resource (every service, even every method), not just at the gateway.
2) OAuth2 and OIDC — the delegated-access framework
Before any code, understand this: OAuth2 is not an authentication protocol at all; it's a framework for delegated authorization. It lets an application access a resource on your behalf without handing that application your password.
When you hand your car to a hotel valet, you don't give them the master key; you give a restricted key (valet key) that only opens the door and moves the car a few meters, while the trunk and glovebox stay locked. An OAuth2 token is exactly that valet key: limited, revocable access instead of your full password. The service never sees your real credentials.
The OAuth2 roles
- Resource Owner: the data owner — usually a human user.
- Client: the app that wants to access the resource on the user's behalf (an SPA, or another service).
- Authorization Server (AS): verifies identity and issues tokens (Keycloak, Auth0, Okta, Spring Authorization Server).
- Resource Server (RS): the API that holds the data and validates tokens (your microservice).
OAuth2 alone only issues an access token, which says "this client is allowed these scopes." It doesn't tell you who the user is. OpenID Connect (OIDC) is a thin layer on OAuth2 that adds an ID Token (always a JWT) carrying identity claims (sub, email, name) plus a standard /userinfo endpoint. Golden rule: use the access token for authz, and the ID token to learn the user's identity. Never send an ID token as an API access credential.
The Authorization Code + PKCE flow
This is today's standard flow for user-facing apps (web, mobile, SPA). PKCE (Proof Key for Code Exchange, pronounced "pixy") adds a protection layer that neutralizes the "authorization-code interception" attack. In OAuth 2.1, PKCE is mandatory for all clients — not just mobile.
The mechanism is simple: the client generates a random code_verifier, sends its SHA-256 hash (code_challenge) in the first request, and at the end, during the code exchange, sends the raw code_verifier. The server re-computes the hash and compares. Even if an attacker steals the code, it's useless without the verifier.
Sequence diagram of the Authorization Code + PKCE flow / نمودار ترتیبی جریان Authorization Code + PKCE:
sequenceDiagram
participant U as User (Browser)
participant C as Client App
participant AS as Authorization Server
participant RS as Resource Server (API)
C->>C: create code_verifier + code_challenge (S256)
U->>C: click "Login"
C->>AS: /authorize?code_challenge=...&scope=openid orders.read
AS->>U: login + consent page
U->>AS: authenticate (password / MFA)
AS->>C: redirect back with authorization code
C->>AS: /token (code + code_verifier)
AS->>AS: verify hash(code_verifier) == code_challenge
AS->>C: access_token (+ id_token + refresh_token)
C->>RS: GET /orders (Authorization: Bearer access_token)
RS->>RS: validate JWT signature, iss, aud, exp
RS->>C: 200 OK (data)
If in 2026 someone tells you "use the implicit flow for your SPA," their knowledge is stale. The implicit grant (which returned the access token directly in the URL) has been removed because tokens leaked into browser history and logs. Likewise Resource Owner Password Credentials (ROPC), where the app takes the user's password directly — that violates the entire OAuth philosophy and is removed in OAuth 2.1. The right answer for SPA and mobile: Authorization Code + PKCE, full stop.
The Client Credentials flow — for machine-to-machine
When there's no user in the loop — say inventory service calling pricing service — there's no user to log in. Here the Client Credentials grant is used: the service itself obtains a token with its client_id and client_secret (or better, a private_key_jwt).
// A ServiceClient obtaining a token via client_credentials and calling another service
// Spring Security 6.5 — OAuth2 Client (servlet)
@Configuration
public class M2mClientConfig {
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clients,
OAuth2AuthorizedClientRepository authorizedClients) {
OAuth2AuthorizedClientProvider provider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials() // grant type = client_credentials
.build();
DefaultOAuth2AuthorizedClientManager manager =
new DefaultOAuth2AuthorizedClientManager(clients, authorizedClients);
manager.setAuthorizedClientProvider(provider);
return manager;
}
}
spring:
security:
oauth2:
client:
registration:
pricing-client:
provider: keycloak
client-id: inventory-svc
client-secret: ${INVENTORY_CLIENT_SECRET} # from Vault, not a file!
authorization-grant-type: client_credentials
scope: pricing.read
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/prod
A common mistake is giving every service a client with broad scopes ("just make it work"). Seniors take least privilege seriously: each client gets only the scopes it truly needs. If inventory only reads prices, give it pricing.read, not pricing.write. When a service leaks — and one day it will — that difference is the difference between a small incident and a catastrophe.
Because public clients (SPA, mobile) cannot keep a secret safe — their code runs on the user's device and anyone can extract it. Without PKCE, if an attacker steals the authorization code in transit (say via a malicious app that registered the same URI scheme), they can exchange it for a token. PKCE closes this: a code is worthless without the code_verifier, which only lives in the original client's memory. OAuth 2.1 made it mandatory even for confidential clients, because defense in depth is essentially free.
3) JWT — structure, validation, and pitfalls
JWT (JSON Web Token, pronounced "jot") is a self-contained token: everything needed to validate it lives inside it, with no database lookup per request. That is exactly what makes JWT attractive for stateless microservices.
A JWT is like a concert ticket with a hologram stamp. It says "Seat B12, valid until 10pm" (that's the payload, easily readable), and it has a special hologram that's hard to forge (that's the signature). The door usher doesn't need to call headquarters to verify you — they just look at the hologram. But note: since the ticket isn't encrypted, anyone can read "Seat B12." A JWT gives no confidentiality, only integrity.
Structure: three dot-separated parts
A JWT is three Base64URL parts separated by .: header.payload.signature.
eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0 ← Header {"alg":"RS256","kid":"k1"}
.eyJzdWIiOiJhbGljZSIsImlzcyI6Imh0dHBz... ← Payload {"sub":"alice","iss":"...","exp":...}
.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-R... ← Signature
- Header: the signing algorithm (
alg) and key id (kid). - Payload: the claims. Standard ones:
iss(issuer),sub(subject/user),aud(audience),exp(expiry),iat(issued at),nbf(not before),jti(unique token id). - Signature: the cryptographic signature over the first two parts.
Since the payload is just Base64 (not encryption), anyone holding the token can read its contents. I've repeatedly seen teams put national IDs, emails, or even internal flags into a JWT. That's information leakage. If you need confidentiality, reach for JWE (JSON Web Encryption), or simply don't put sensitive data in the token. JWT gives integrity, not confidentiality.
Validation in Spring — the resource server
In Spring, turning a microservice into a resource server is almost frictionless. You add the dependency and provide the issuer-uri; Spring fetches the public keys from the issuer's JWKS (JSON Web Key Set) endpoint itself.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/prod
# Spring auto-discovers JWKS via {issuer}/.well-known/openid-configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize (prePostEnabled defaults to true)
public class ResourceServerConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
// Stateless microservice: no session, no session-based CSRF
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
But the defaults are not enough. By default Spring checks exp, nbf, and iss, but it does not check aud (audience) unless you add it. This is a real security trap: if you don't check audience, a token issued for service A is accepted by service B too (token substitution).
// Add a custom validator for the audience
@Bean
JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("orders-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
return decoder;
}
Role mapping is also on you. Keycloak puts roles under realm_access.roles, not in scope. You need a converter:
JwtAuthenticationConverter jwtAuthConverter() {
JwtAuthenticationConverter conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(jwt -> {
// take scopes (Spring default prefix: SCOPE_)
var scopes = new JwtGrantedAuthoritiesConverter().convert(jwt);
// plus Keycloak realm roles with a ROLE_ prefix
Map<String, Object> realm = jwt.getClaim("realm_access");
if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
roles.forEach(r -> scopes.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
return scopes;
});
return conv;
}
This is where JWT's self-contained nature shines. The AS signs the token with its private key and publishes the matching public key at a standard JWKS endpoint. The resource server fetches these public keys once (then caches them, keyed by kid) and from then on verifies the signature locally with no network round-trip; it only re-fetches JWKS when it sees an unknown kid (supporting key rotation). That's why JWT is ideal for stateless microservices. The antithesis is token introspection (RFC 7662), where the RS asks the AS "is this token still valid?" on every request — giving instant revocation but sacrificing statelessness and performance. Choosing between the two is a classic senior trade-off.
The most famous JWT attack: the attacker changes the header to {"alg":"none"}, strips the signature, and bad libraries accept it as "valid"! Because it says "there is no algorithm, so no signature is needed." The second dangerous attack is algorithm confusion: a token signed with RS256 (asymmetric) is changed to HS256 (symmetric), and the server's public key is used as the HMAC secret — because the public key is public! Defense: always pin the allowed algorithm server-side and reject the rest. The Nimbus library Spring uses picks the right algorithm from JWKS and won't accept none, but if you're parsing JWTs by hand, beware.
Expiry, rotation, and revocation — the hardest part
JWT's self-contained nature has another sharp edge: revocation is nearly impossible. Once issued, a token is valid until its exp, even if you block the user or they change their password. The server doesn't check the database; it just looks at the signature and exp.
The industry-standard solution:
- Short-lived access token (5–15 minutes): being short, its abuse window is small.
- Long-lived, revocable refresh token: stored in the AS database and truly revocable. The client uses it to get a fresh access token.
- Refresh token rotation: every time a refresh is used, a new one is issued and the old one is revoked. If a refresh is used twice (a sign of theft), revoke the whole family.
If you truly need instant revocation (e.g. "cut this user off right now"), you have three options:
- jti denylist: keep revoked tokens'
jtiin Redis with a TTL equal toexp. Every resource server checks before accepting. Cost: one lookup per request — you're no longer fully stateless. - token versioning: keep a version number on the user profile and put it in the claim; on block, bump the version.
- key rotation: on a wide breach, rotate the signing key so all old tokens die at once.
With a pure stateless token you can't — and that's the golden point the interviewer is fishing for. You must add server state. Three patterns: (1) a jti-based denylist in a cache like Redis with a TTL; (2) a token version in the user database compared against a claim; (3) in a crisis, signing-key rotation. But the best defense is preventive: short-lived access tokens (5–15 min) plus refresh token rotation. That shrinks the danger window enough that a denylist is often unnecessary. If you say "a JWT is easy to revoke," the interviewer knows you didn't understand its stateless nature.
An access token is a short-lived key sent on every request to the resource server and validated statelessly; because it circulates so much, it's deliberately short-lived. A refresh token is a long-lived and more sensitive credential sent only to the authorization server to obtain a fresh access token; it's stored in the AS database and is therefore revocable. Never send an access token where a refresh is expected, and vice versa. In the browser, a refresh token belongs in an HttpOnly, Secure, SameSite cookie — not in localStorage, which is defenseless against XSS.
4) Token propagation and edge auth — from gateway to service depth
Now the question: where does the token enter the system, and how does it travel between services? The common pattern is authenticating at the edge (the API gateway), and then token propagation into the depths.
Edge authentication and token propagation topology / توپولوژی احراز لبه و انتشار توکن:
flowchart LR
Client -->|Bearer JWT| GW[API Gateway]
GW -->|validate + TokenRelay| OrderSvc[Order Service]
OrderSvc -->|Bearer JWT propagated| PaymentSvc[Payment Service]
OrderSvc -->|Bearer JWT propagated| InventorySvc[Inventory Service]
GW -. JWKS .-> AS[(Authorization Server)]
OrderSvc -. JWKS .-> AS
PaymentSvc -. JWKS .-> AS
A common architectural mistake: "the gateway checks the token, so the internal services are safe and don't need to re-check." That's the same "trusted internal network" assumption that's dead under zero trust. If an attacker gets into the internal network (SSRF, a compromised pod, a misconfig), they hit services directly and bypass the gateway. The rule: the gateway does the heavy work (rate limiting, token format, initial auth), but each service re-validates the token and enforces authz. The edge is the first filter, not the only filter.
Token relay in Spring Cloud Gateway
When the gateway itself authenticates the user with oauth2Login() (say for a classic web app), the TokenRelay filter automatically forwards the user's access token downstream:
spring:
cloud:
gateway:
default-filters:
- TokenRelay= # forwards the access token to all routes
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
There are two propagation styles, and you should choose consciously:
- Pass-through (relay): you hand the same user token from hop to hop. Simple, but the token's
audmust accept every service on the path and the user's scope is present everywhere — which breaks least privilege. - Token Exchange (RFC 8693): each service gives the incoming token to the AS and receives a new, narrower token for the next hop. More secure and traceable, but adds a round-trip to the AS on every hop. For sensitive paths (payments) it's worth it.
Not necessarily — it's a trade-off. Simple relay (pass-through) is cheap and easy, but it means one broadly-scoped token travels deep into the system and the user context is exposed everywhere; if a deep service leaks, the attacker holds a token with the user's full access. The safer pattern is Token Exchange (RFC 8693): each hop gets a narrower token with the exact aud and scope of the next hop, honoring least privilege and traceability — at the cost of latency and load on the AS. Senior answer: relay for ordinary paths, token exchange (or even a separate service-scoped token) for high-risk paths (payment, data deletion).
mTLS and service-mesh identity
So far we've talked about authenticating the user. But how do services trust each other? Here mTLS (Mutual TLS) enters: in ordinary TLS only the server presents a certificate, but in mTLS both sides present certificates. So the payment service not only talks encrypted, but cryptographically proves "I really am payment."
Ordinary TLS is like you seeing the merchant's ID card while they don't know you. mTLS is like a private club's secret handshake where both people must perform the right move, or the door won't open. Nobody can impersonate the other member, because it requires a cryptographic certificate.
Managing certificates by hand for hundreds of services is a nightmare. Here service mesh (Istio, Linkerd) and the SPIFFE/SPIRE standard come in:
- SPIFFE (Secure Production Identity Framework For Everyone): a standard that gives each workload a structured identity in the form of a URI like
spiffe://prod.example.com/ns/payments/sa/payment-svc. This identity doesn't depend on IP or hostname, but on the workload itself. - SPIRE: the runtime implementation of SPIFFE that issues these identities as short-lived X.509 certificates (called SVID, SPIFFE Verifiable Identity Document) and rotates them automatically.
- Istio ships SPIFFE-compliant identity natively: Istiod's internal CA issues X.509-SVIDs and establishes mTLS between all services transparently (with no application code change). mTLS overhead is typically just 1–2 milliseconds.
Don't confuse them — mTLS and OAuth are two complementary layers, not rivals:
- mTLS / SPIFFE answers "which service is talking to me?" (workload identity, transport layer).
- OAuth2 / JWT answers "which user is behind this request and what may they do?" (user identity, application layer). A mature architecture has both: the mesh encrypts and authenticates the service-to-service channel, and the JWT carries the user context.
No, it complements it and works at a different layer. mTLS proves workload identity (which service) at the transport layer and encrypts the channel — the zero-trust answer to "trusting the internal network." But mTLS doesn't know who the end user is or what they may do; that lives in the JWT carried at the application layer. Senior pattern: a service mesh (Istio + SPIFFE/SPIRE) establishes mTLS transparently with no extra code, and the application enforces the user's JWT for authz over that same channel. Dropping one in favor of the other is a common security mistake.
5) RBAC vs ABAC vs ReBAC — the heart of authorization
Now the deepest part: how do we decide a user may perform an action? Three big models exist, and the wrong choice builds years of technical debt.
RBAC — Role-Based Access Control
RBAC is the simplest: you give the user roles (ADMIN, EDITOR, VIEWER), and each role gets permissions. The decision is: "does the user have a role that allows this action?"
RBAC is like an employee badge. Your badge has a "level"; the "management" badge opens the 5th floor, the "regular staff" badge doesn't. The doors only check the level, not exactly who you are. Simple, understandable, but coarse-grained: it can't say "only your own room's door."
ABAC — Attribute-Based Access Control
ABAC decides based on attributes: the user's attribute (department=finance), the resource's attribute (owner=alice, classification=secret), and the environment's attribute (time, ip, device). The decision is a function of these attributes: "allow if user.department == resource.department and it's business hours."
If RBAC is a badge door, ABAC is a smart guard holding a rule in mind: "anyone from finance, during business hours, from inside the building, may enter the financial archive." The decision is composed on the fly from several attributes, not from a fixed label. Very flexible, but reasoning about "who has access to what?" gets harder.
ReBAC — Relationship-Based Access Control
ReBAC makes the decision based on a graph of relationships: "alice can edit this document, because she's an editor of the folder the document lives in." This is the model Google Zanzibar introduced, powering Drive, Calendar, and YouTube. Data is stored as relationship tuples: document:budget#editor@user:alice.
ReBAC is like saying "you can access this photo album because you're a friend of the album's owner," or "because you're a member of a group the album is shared with." Access comes from a path through the relationship graph, not from a fixed role or attribute. This is exactly what you need for the nested "shares" of SaaS products.
Comparison table:
| Dimension | RBAC | ABAC | ReBAC |
|---|---|---|---|
| Decision basis | Roles | Attributes (user/resource/env) | Relationships in a graph |
| Granularity | Coarse | Fine, dynamic | Fine, object-level |
| Example question | "Is admin?" | "Same dept and business hours?" | "Editor of this folder?" |
| Strength | Simplicity, easy audit | Flexibility, context-aware | Nested "shares", ownership |
| Weakness | Role explosion | Hard to reason/audit | Needs dedicated infra |
| Tool example | Spring hasRole |
OPA/Rego, Cedar | Zanzibar, OpenFGA, SpiceDB |
| When? | Few, stable roles | Context-driven rules | Hierarchical ownership/sharing |
Pure RBAC explodes at scale. When you need fine granularity, roles proliferate: EDITOR_REGION_EU, EDITOR_REGION_US, VIEWER_FINANCE_2024... I've seen projects reach thousands of roles where nobody knows what each does. That's the sign your problem is inherently attribute- or relationship-based and you should move to ABAC or ReBAC. If you're encoding data into the role name (EDITOR_REGION_EU), you're really implementing ABAC badly.
In practice, mature systems are hybrid, and that's completely natural. RBAC for "who is a system admin" (coarse, stable), plus ReBAC or ABAC for "who can see this specific document" (fine, dynamic). GitHub is the textbook example: organization roles (owner/member) + fine relationship-based access to each repo. The mistake is thinking you must pick one. The right question is: "which model do I use for each decision?"
Both represent permissions but from two different vantage points. A scope belongs to the client and says "what this application may do on the user's behalf" (e.g. orders.read); the user approves it during consent and it's defined in OAuth. A role belongs to the user and says "what standing this person has in the system" (e.g. ADMIN); it's an application-domain concept, not OAuth itself. A subtle point: an authorized request should satisfy both — the client has the required scope and the user has the required role/relationship. In Spring, scopes map to authorities with a SCOPE_ prefix and roles with ROLE_, and you can combine both in @PreAuthorize. Common mistake: relying only on scope and forgetting the user's role/ownership check (again the source of IDOR).
Method security in Spring — @PreAuthorize
Spring Security supports @EnableMethodSecurity (which since Spring Security 6 replaces the old @EnableGlobalMethodSecurity, with prePostEnabled defaulting to true). With @PreAuthorize you can write both RBAC and simple ABAC using SpEL:
@Service
public class DocumentService {
// Simple RBAC: ADMIN role only
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { /* ... */ }
// scope-based (from the JWT)
@PreAuthorize("hasAuthority('SCOPE_documents.write')")
public Document create(DocumentDto dto) { /* ... */ }
// ABAC: the user must own the document (checking a resource attribute)
@PreAuthorize("#doc.owner == authentication.name")
public Document update(Document doc) { /* ... */ }
// Combining role and attribute
@PreAuthorize("hasRole('EDITOR') and #doc.department == authentication.principal.claims['department']")
public Document edit(Document doc) { /* ... */ }
// @PostAuthorize: filter the output after execution (careful: the method ran!)
@PostAuthorize("returnObject.owner == authentication.name")
public Document findById(Long id) { /* ... */ }
}
@PostAuthorize checks after the method runs. If your method has side-effects (writing to the DB, publishing an event), that side-effect happens even if authz is denied — only the return value is withheld from the user. Use @PostAuthorize only for read-only, side-effect-free methods. For write methods, always @PreAuthorize, which decides before execution.
A common anti-pattern: sprinkling access checks as ifs here and there in controller and service code. After a few months, nobody knows exactly what rule an endpoint has, and one forgotten if = a hole. Keep authz in an explicit, centralized layer: either clean @PreAuthorize annotations, or better, an external policy engine we cover next. The rule: "separate policy from code."
Policy engines — when SpEL isn't enough
When authz rules become complex, frequently changing, or shared across services (perhaps multiple languages), pull them out of the code and hand them to a policy engine. This is Policy as Code.
OPA (Open Policy Agent) is a general-purpose CNCF policy engine where you write policies in the Rego language. It usually runs as a sidecar beside each service; the service sends a JSON context and OPA returns allow/deny.
# policy: authz.rego — a simple ABAC/ReBAC rule
package httpapi.authz
default allow := false
# admins get everything
allow if {
input.user.roles[_] == "admin"
}
# a user can read their own document
allow if {
input.method == "GET"
input.resource.owner == input.user.id
}
# finance members during business hours can view finance reports (ABAC)
allow if {
input.resource.type == "finance_report"
input.user.department == "finance"
input.time.hour >= 8
input.time.hour < 18
}
Imagine each service, instead of knowing the law itself, has a legal advisor at its side (the sidecar). The service asks "can alice delete this?", hands over the case file (context JSON), and the advisor answers yes/no based on the shared law book (Rego policies). The benefit: the law is maintained in one place, all services judge identically, and changing the law doesn't touch service code.
Cedar (from AWS, 2023) is a purpose-built authorization policy language that supports RBAC, ABAC, and ReBAC together and focuses on analyzability and speed; AWS benchmarks report it tens of times faster than Rego in access scenarios. Unlike Rego, which is general-purpose, Cedar is deliberately scoped to authz so that policies can be formally analyzed.
// A Cedar policy — ReBAC + RBAC
permit(
principal,
action == Action::"editDocument",
resource
)
when {
resource.owner == principal ||
principal in resource.editors // relationship: member of the resource's editors
};
Zanzibar / OpenFGA / SpiceDB are for pure ReBAC at large scale. You define a model with relationships and store data as tuples, then ask a "check" query that traverses the graph.
Zanzibar-style ReBAC relationship graph / گراف روابط ReBAC به سبک Zanzibar:
flowchart TD
U[user:alice] -->|member| G[group:engineering]
G -->|editor| F[folder:specs]
F -->|parent| D[document:api-design]
U -->|viewer| D2[document:roadmap]
Q{{"Check: can alice edit document:api-design?"}}
Q -.follows.-> U
Q -.->|yes: member→editor→parent| D
# OpenFGA model (conceptual) — relationships and their inheritance
# type document
# relations
# define parent: [folder]
# define editor: [user, group#member] or editor from parent
# define viewer: [user] or editor
It depends on the shape of the decision. OPA/Rego (or Cedar) is great when the decision is a function of the attributes and context of the same request — role, department, time, IP — i.e. inherently ABAC. All needed data is either in the token or the payload, and the policy is a pure function. But when the decision depends on nested, graph-like relationships in the data — "alice can see this document because she's a member of a group that's an editor of the document's parent folder" — that's ReBAC and you need a relationship database like Zanzibar/OpenFGA/SpiceDB to store and traverse that graph. Trying to model a deep relationship graph in Rego becomes slow, and you'd have to load the relationship data into OPA somehow. Short answer: attribute-based → OPA/Cedar; graph-relationship-based → the Zanzibar family.
The classic trade-off. Inline (@PreAuthorize, code inside the service): zero latency, simple to start, but policy gets duplicated across services and languages and diverges, and central auditing is hard. External (OPA sidecar, central PDP): a single source of truth for policy, auditable, language-agnostic, and changeable without redeploying services — but adds a hop and a new operational dependency. The balanced industry pattern: author and distribute policies centrally, but make the decision locally (a sidecar next to the service) so you have neither a central point of failure nor network latency. This is exactly OPA's bundle-distribution model.
6) The RBAC data model and token denylist — dual-dialect SQL
A lot of RBAC lives in the relational database: user, role, permission, and the join table. Since both Oracle and PostgreSQL are common in projects, I'll show the dialect differences separately.
The base RBAC schema (identical in both, with data-type differences):
-- PostgreSQL
CREATE TABLE app_user (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE app_role (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE -- e.g. ADMIN, EDITOR
);
CREATE TABLE user_role (
user_id BIGINT NOT NULL REFERENCES app_user(id),
role_id BIGINT NOT NULL REFERENCES app_role(id),
PRIMARY KEY (user_id, role_id)
);
-- Oracle (19c/23ai)
CREATE TABLE app_user (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR2(100) NOT NULL UNIQUE,
enabled NUMBER(1) DEFAULT 1 NOT NULL, -- Oracle: native BOOLEAN only arrived in 23ai
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE TABLE app_role (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR2(64) NOT NULL UNIQUE
);
CREATE TABLE user_role (
user_id NUMBER NOT NULL REFERENCES app_user(id),
role_id NUMBER NOT NULL REFERENCES app_role(id),
PRIMARY KEY (user_id, role_id)
);
- BOOLEAN: PostgreSQL has a native
BOOLEAN; Oracle didn't in SQL until 23ai and usedNUMBER(1)with0/1(in SQL; PL/SQL always hadBOOLEAN). - IDENTITY: both support
GENERATED ALWAYS AS IDENTITY(Oracle since 12c); older Oracle used theSEQUENCE + TRIGGERpattern. - Empty string = NULL: in Oracle,
''equalsNULL! SoWHERE username = ''never returns anything. In PostgreSQL''is a real value. This difference can create a security bug in an access check. - VARCHAR2 vs VARCHAR: in Oracle always
VARCHAR2(sinceVARCHARbehavior isn't guaranteed); in PostgreSQLVARCHAR.
Querying a user's roles with string aggregation — here the dialect really differs:
-- PostgreSQL: STRING_AGG
SELECT u.username, STRING_AGG(r.name, ',' ORDER BY r.name) AS roles
FROM app_user u
JOIN user_role ur ON ur.user_id = u.id
JOIN app_role r ON r.id = ur.role_id
WHERE u.username = 'alice'
GROUP BY u.username;
-- Oracle: LISTAGG
SELECT u.username,
LISTAGG(r.name, ',') WITHIN GROUP (ORDER BY r.name) AS roles
FROM app_user u
JOIN user_role ur ON ur.user_id = u.id
JOIN app_role r ON r.id = ur.role_id
WHERE u.username = 'alice'
GROUP BY u.username;
A jti denylist table (token revocation) — with an upsert that differs completely between dialects:
-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO revoked_token (jti, revoked_at, expires_at)
VALUES ('a1b2c3', now(), now() + INTERVAL '15 minutes')
ON CONFLICT (jti) DO NOTHING;
-- Oracle: MERGE
MERGE INTO revoked_token t
USING (SELECT 'a1b2c3' AS jti FROM dual) s
ON (t.jti = s.jti)
WHEN NOT MATCHED THEN
INSERT (jti, revoked_at, expires_at)
VALUES ('a1b2c3', SYSTIMESTAMP, SYSTIMESTAMP + INTERVAL '15' MINUTE);
The SQL table above is good for understanding, but in production put a jti denylist in Redis with a TTL (equal to the time remaining until exp), not in a relational database. Reason: this lookup runs on every request to the resource server; it must be microsecond-fast, in-memory, and self-cleaning. A SQL query per API request will bring the system to its knees and fill the table with expired records. Redis's TTL deletes the record exactly when the token expires anyway.
7) Secrets management — Vault, Sealed Secrets, and safe patterns
Tokens, keys, client secrets, database passwords — all secrets. The biggest beginner security mistake is hard-coding a secret in code or committing it to Git. A secret that once entered Git history is there forever (even if you delete it later).
A secret in a config file = a secret in Git = a secret on every developer's laptop and in every backup. A secret in a Docker image ENV variable is no better; it spills out via docker inspect or in a crash log. Even a Kubernetes Secret is by default only Base64, not encryption — anyone with read access to the namespace can decode it. Secrets need dedicated management.
HashiCorp Vault is the industry's de-facto standard. Two key capabilities:
- Static secrets (KV): stores passwords encrypted and serves them with fine-grained access control and an audit trail.
- Dynamic secrets: this is the golden point — Vault can generate a short-lived database credential on the fly that expires by itself. The application never has a static database password; each time it gets a temporary, few-minute credential. If it leaks, it dies on its own.
# Spring Cloud Vault — secrets are injected as a property source
# bootstrap/application.yml
spring:
cloud:
vault:
uri: https://vault.example.com:8200
authentication: KUBERNETES # authenticates with the pod's ServiceAccount, no static password
kubernetes:
role: order-service
kv:
enabled: true
backend: secret
default-context: order-service
// After injection, the secret is available like any ordinary property:
@Value("${db.password}") // came from Vault, not a file
private String dbPassword;
In the Kubernetes world several patterns compete:
| Pattern | Idea | Strength | Weakness |
|---|---|---|---|
| Sealed Secrets (Bitnami) | An asymmetrically-encrypted secret that safely goes into Git; only the in-cluster controller decrypts it | True GitOps for secrets | No central management or auto-rotation |
| External Secrets Operator (ESO) | Pulls secrets from Vault/AWS/GCP/Azure and syncs them into a Kubernetes Secret | Industry standard, connects to cloud vaults | The Kubernetes Secret itself is still Base64 |
| Vault Secrets Operator / CSI | Vault injects the secret directly into the pod | Dynamic secrets and auto-rotation | Vault's operational complexity |
If you're just starting and want GitOps, Sealed Secrets is simple and enough: encrypt the secret and safely put it in Git. But once you grow and need auto-rotation, dynamic secrets, and central multi-cluster management, migrate to External Secrets Operator backed by Vault. The community recommends this exact path in 2026. The key point: in both cases the ultimate goal is removing the static secret from human hands.
Because a Kubernetes Secret by default only Base64-encodes the secret, not encrypts — anyone with get secret access in that namespace, or access to etcd, can decode it. Three main weaknesses: (1) it's unencrypted at rest unless you turn on encryption-at-rest in etcd yourself; (2) it has neither auto-rotation nor expiry — the secret is static forever; (3) it has no fine-grained audit trail of "who read which secret when." Production solutions close these gaps: Vault for dynamic, short-lived secrets and full audit, ESO for syncing, and Sealed Secrets for safe GitOps. Senior answer: a secret should never stay static and in human hands; it should be short-lived, self-rotating, and auditable.
Wrap-up
- authn ≠ authz: authentication proves "who you are," authorization decides "what you may do"; valid authn is never authz (the source of IDOR).
- OAuth2/OIDC: OAuth2 is a delegated-access framework, not authentication; OIDC is the identity layer with an ID token. For users Authorization Code + PKCE, for machines Client Credentials; implicit and password grants are dead.
- JWT: self-contained, integrity only (not confidentiality). Check
iss/aud/exp, pin the algorithm (closealg=noneand RS/HS confusion), and know revocation is hard — short access token + refresh rotation, and ajtidenylist in Redis if needed. - Propagation and edge: authenticate at the gateway, but re-enforce authz in every service (defense in depth); relay is simple but token exchange is safer for sensitive paths.
- mTLS/SPIFFE: workload identity at the transport layer, complementing the JWT that carries user identity; a service mesh makes it transparent.
- RBAC/ABAC/ReBAC: role-based (simple, role explosion), attribute-based (flexible, OPA/Cedar), relationship-based (graph, Zanzibar/OpenFGA); real systems are hybrid.
@PreAuthorizeto start, a policy engine when rules get complex or shared. - Secrets: never in Git/config/Base64; Vault (dynamic secrets), ESO, and Sealed Secrets are the safe patterns.
The sentence to carry with you: microservice security means zero trust at every layer — no trusting the network, no trusting a merely-valid token, no trusting a static secret. Every layer proves identity and re-enforces permission.