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). این دو در دو لایه‌ی متفاوت اتفاق می‌افتند، و بزرگ‌ترین اشتباهِ تیم‌های تازه‌کار این است که آن‌ها را در یک نقطه قاطی می‌کنند.

دامِ کلاسیک: authz را با authn عوضی گرفتن

یک اشتباهِ رایج در production: تیم تصور می‌کند «چون کاربر لاگین کرده و توکنِ معتبر دارد، پس مجاز است.» نه! یک توکنِ کاملاً معتبر که ثابت می‌کند کاربر alice است، هیچ چیزی درباره‌ی این‌که alice حق دارد فاکتورِ کاربرِ دیگری را ببیند نمی‌گوید. این دقیقاً منشأ آسیب‌پذیریِ IDOR و Broken Object Level Authorization است که سال‌هاست صدرنشینِ لیستِ OWASP API Security است. authn معتبر بودن، هرگز به‌معنای authz داشتن نیست.

فرق authentication و authorization در یک جمله چیست؟

Authentication ثابت می‌کند «تو کی هستی» (تأییدِ هویت)، و Authorization تعیین می‌کند «اجازه‌ی چه کاری داری» (تأییدِ دسترسی). authn همیشه قبل از authz اتفاق می‌افتد، اما authn موفق هرگز authz را تضمین نمی‌کند. در HTTP، شکستِ authn معمولاً 401 Unauthorized (نامِ بد؛ در واقع یعنی «unauthenticated») و شکستِ authz 403 Forbidden است. نکته‌ی سنیوری: در یک معماریِ زیرو-تراست، authz باید در نزدیک‌ترین نقطه به منبع (هر سرویس، حتی هر متد) دوباره اعمال شود، نه فقط در gateway.


۲) OAuth2 و OIDC — چارچوبِ تفویضِ دسترسی

قبل از هر کد، باید بدانی OAuth2 اصلاً یک پروتکلِ احراز هویت نیست؛ یک چارچوبِ تفویضِ اجازه‌دهی (delegated authorization) است. یعنی به یک اپلیکیشن اجازه می‌دهی از طرفِ تو به منبعی دسترسی داشته باشد، بدون این‌که رمزت را به آن بدهی.

کلیدِ پارکینگِ هتل (valet key)

وقتی ماشینت را به 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‌ای که داده را نگه می‌دارد و توکن را اعتبارسنجی می‌کند (میکروسرویسِ تو).
OIDC = یک لایه‌ی هویت روی OAuth2

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)
implicit و password grant مرده‌اند

اگر در سالِ ۲۰۲۶ کسی به تو گفت «برای 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
قضاوتِ سنیور: scope را کوچک نگه دار

یک اشتباهِ رایج این است که به هر سرویس یک client با scopeهای گسترده می‌دهند («فقط بگذار کار کند»). سنیورها اصلِ least privilege را جدی می‌گیرند: هر client فقط scopeهایی را می‌گیرد که واقعاً لازم دارد. اگر سرویسِ inventory فقط قیمت می‌خواند، pricing.read بگیرد نه pricing.write. وقتی یک سرویس نشت کند — و یک روز نشت می‌کند — این تفاوتِ بینِ یک حادثه‌ی کوچک و فاجعه است.

چرا PKCE اضافه شد وقتی client_secret داریم؟

چون کلاینت‌های 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 رمزگذاری نشده — راز داخلش نگذار!

چون 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;
}
یک resource server چطور JWT را بدونِ زنگ‌زدن به AS در هر درخواست اعتبارسنجی می‌کند؟

اینجاست که خودحامل بودنِ JWT می‌درخشد. AS با کلیدِ خصوصیِ خود توکن را امضا می‌کند و کلیدِ عمومیِ متناظر را در یک endpointِ استانداردِ JWKS منتشر می‌کند. resource server یک‌بار (و بعد به‌صورتِ کش‌شده و با احترام به kid) این کلیدهای عمومی را می‌گیرد و از آن به بعد امضا را محلی و بدونِ رفت‌وبرگشتِ شبکه‌ای وارسی می‌کند؛ فقط زمانی که یک kid ناشناخته ببیند JWKS را دوباره می‌کشد (پشتیبانی از چرخشِ کلید). به همین دلیل JWT برای میکروسرویسِ بی‌حالت ایده‌آل است. آنتی‌تزِ این، token introspection (RFC 7662) است که در آن RS در هر درخواست از AS می‌پرسد «این توکن هنوز معتبر است؟» — باطل‌سازیِ فوری می‌دهد اما stateless بودن و کارایی را قربانی می‌کند. انتخابِ بین این دو یک بده‌بستانِ کلاسیکِ سنیوری است.

دامِ مرگبار: `alg: none`

معروف‌ترین حمله‌ی JWT: مهاجم header را به {"alg":"none"} تغییر می‌دهد، امضا را حذف می‌کند، و کتابخانه‌های بد آن را «معتبر» می‌پذیرند! چون گفته «الگوریتمی وجود ندارد پس امضا لازم نیست». حمله‌ی دومِ خطرناک الگوریتم confusion است: توکنی که با RS256 (کلیدِ نامتقارن) امضا شده را به HS256 (کلیدِ متقارن) تغییر می‌دهند و کلیدِ عمومیِ سرور را به‌عنوان secretِ HMAC استفاده می‌کنند — چون کلیدِ عمومی، عمومی است! دفاع: همیشه الگوریتمِ مجاز را در سمتِ سرور pin کن و بقیه را رد کن. کتابخانه‌ی Nimbus که Spring استفاده می‌کند از JWKS الگوریتمِ درست را می‌گیرد و none را نمی‌پذیرد، اما اگر دستی JWT پارس می‌کنی، مراقب باش.

انقضا، چرخش و باطل‌سازی — سخت‌ترین بخش

خودحامل بودنِ JWT یک لبه‌ی تیزِ دیگر دارد: باطل‌سازی (revocation) تقریباً غیرممکن است. وقتی توکن صادر شد، تا لحظه‌ی exp معتبر است، حتی اگر کاربر را بلاک کنی یا رمزش را عوض کند. سرور دیتابیس را چک نمی‌کند؛ فقط امضا و exp را نگاه می‌کند.

الگوی طلایی: access token کوتاه + refresh token چرخان

راه‌حلِ استانداردِ صنعت:

  • Access token کوتاه‌عمر (۵ تا ۱۵ دقیقه): چون کوتاه است، پنجره‌ی سوءاستفاده کوچک است.
  • Refresh token بلندعمرِ قابل‌ابطال: در دیتابیسِ AS ذخیره می‌شود و واقعاً می‌شود باطلش کرد. کلاینت با آن access tokenِ تازه می‌گیرد.
  • Refresh token rotation: هر بار که refresh استفاده شد، یکی نو صادر و قبلی باطل می‌شود. اگر یک refresh دو بار استفاده شود (نشانه‌ی سرقت)، کلِ خانواده را باطل کن.

اگر واقعاً به باطل‌سازیِ فوری نیاز داری (مثلاً «کاربر را همین الان قطع کن»)، سه گزینه داری:

  1. jti denylist: jti توکن‌های باطل‌شده را در Redis با TTL برابرِ exp نگه دار. هر resource server قبل از پذیرش چک می‌کند. هزینه: یک lookup به‌ازای هر درخواست — دیگر کاملاً stateless نیستی.
  2. token versioning: یک شماره‌ی نسخه در پروفایلِ کاربر نگه دار و در claim بگذار؛ هنگامِ بلاک، نسخه را افزایش بده.
  3. key rotation: در نشتِ گسترده، کلیدِ امضا را بچرخان تا همه‌ی توکن‌های قدیمی یک‌جا بمیرند.
چطور یک JWT را قبل از انقضایش باطل می‌کنی؟

با یک توکنِ خالصِ stateless نمی‌توانی — و این نکته‌ی طلایی است که مصاحبه‌گر دنبالش است. باید حالتِ سروری اضافه کنی. سه الگو: (۱) denylist بر پایه‌ی jti در یک کش مثلِ Redis با TTL؛ (۲) token version در دیتابیسِ کاربر که با claim مقایسه می‌شود؛ (۳) در بحران، چرخشِ کلیدِ امضا. اما بهترین دفاع پیشگیرانه است: access tokenهای کوتاه‌عمر (۵–۱۵ دقیقه) به‌علاوه‌ی refresh token rotation. با این کار پنجره‌ی خطر آن‌قدر کوچک می‌شود که اغلبِ اوقات denylist لازم نمی‌شود. اگر بگویی «JWT را می‌شود راحت باطل کرد»، مصاحبه‌گر می‌فهمد ماهیتِ stateless را نفهمیده‌ای.

access token و refresh token چه فرقی دارند؟

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
احراز در لبه، اعمال در عمق (defense in depth)

یک اشتباهِ معماریِ رایج: «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 در برابر token exchange

دو سبکِ انتشار وجود دارد و باید آگاهانه انتخاب کنی:

  • Pass-through (relay): همان توکنِ کاربر را دست‌به‌دست می‌کنی. ساده، اما aud توکن باید همه‌ی سرویس‌های مسیر را بپذیرد و scope کاربر همه‌جا حاضر است — که اصلِ least privilege را می‌شکند.
  • Token Exchange (RFC 8693): هر سرویس توکنِ ورودی را به AS می‌دهد و یک توکنِ جدید و محدودتر برای گامِ بعد می‌گیرد. امن‌تر و قابلِ‌ردگیری، اما یک رفت‌وبرگشت به AS در هر پرش اضافه می‌کند. برای مسیرهای حساس (مثلاً پرداخت) ارزشش را دارد.
آیا access token را باید بینِ همه‌ی میکروسرویس‌ها relay کرد؟

نه لزوماً — این یک بده‌بستان است. 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 معمولاً فقط ۱ تا ۲ میلی‌ثانیه است.
دو لایه‌ی هویت: کاربر و workload

اشتباه نکن — mTLS و OAuth دو لایه‌ی مکمل هستند، نه رقیب:

  • mTLS / SPIFFE به سؤالِ «کدام سرویس با من حرف می‌زند؟» جواب می‌دهد (هویتِ workload، لایه‌ی transport).
  • OAuth2 / JWT به سؤالِ «کدام کاربر پشتِ این درخواست است و چه اجازه‌ای دارد؟» جواب می‌دهد (هویتِ کاربر، لایه‌ی application). یک معماریِ بالغ هر دو را دارد: mesh کانالِ سرویس‌به‌سرویس را رمزنگاری و احراز می‌کند، و JWT contextِ کاربر را حمل می‌کند.
mTLS جای JWT را می‌گیرد؟

نه، مکملِ آن است و در لایه‌ی متفاوتی کار می‌کند. 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-محور مالکیت/اشتراکِ سلسله‌مراتبی
انفجارِ نقش (role explosion) — بیماریِ RBAC خالص

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 و role در OAuth/authz چیست؟

هر دو مجوز را نمایندگی می‌کنند اما از دو منظرِ متفاوت. 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

@PostAuthorize بعد از اجرای متد چک می‌کند. اگر متدت side-effect دارد (نوشتن در دیتابیس، ارسالِ event)، آن side-effect حتی اگر authz رد شود اتفاق می‌افتد — فقط خروجی به کاربر برنمی‌گردد. @PostAuthorize را فقط برای متدهای خواندنیِ بی‌side-effect استفاده کن. برای متدهای نویسنده، همیشه @PreAuthorize که قبل از اجرا تصمیم می‌گیرد.

منطقِ authz را در کنترلرها پخش نکن

یک آنتی‌پترنِ رایج: 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
}
OPA مثلِ یک مشاورِ حقوقیِ مستقل

تصور کن هر سرویس به‌جای این‌که خودش قانون بلد باشد، یک مشاورِ حقوقیِ کنارِ دستش (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 استفاده کنی و کِی از Zanzibar/OpenFGA؟

به شکلِ تصمیم بستگی دارد. OPA/Rego (یا Cedar) وقتی عالی است که تصمیم تابعی از ویژگی‌ها و contextِ همان درخواست باشد — نقش، بخش، ساعت، IP — یعنی ذاتاً ABAC. همه‌ی دادهٔ لازم یا در توکن است یا در payload، و policy یک تابعِ خالص است. اما وقتی تصمیم به روابطِ تودرتو و گرافیِ داده وابسته است — «alice می‌تواند این سند را ببیند چون عضوِ گروهی است که editorِ پوشه‌ی والدِ سند است» — این ReBAC است و به یک دیتابیسِ روابط مثلِ Zanzibar/OpenFGA/SpiceDB نیاز داری که آن گراف را ذخیره و پیمایش کند. تلاش برای مدل‌کردنِ گرافِ عمیقِ روابط در Rego، هم کند می‌شود هم داده‌ی روابط را باید یک‌جوری داخلِ OPA بریزی. جوابِ کوتاه: ویژگی‌محور → OPA/Cedar؛ رابطه‌محورِ گرافی → Zanzibar-family.

policy را داخلِ اپلیکیشن بگذاریم یا در یک سرویسِ جدا؟

بده‌بستانِ کلاسیک. درون‌خطی (@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)
);
تفاوت‌های گویش که در RBAC واقعاً به‌درد می‌خورند
  • 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 تضمین‌شده نیست)؛ در PostgreSQL VARCHAR.

پرس‌وجوی نقش‌های یک کاربر با تجمیعِ رشته‌ای — اینجا گویش واقعاً فرق می‌کند:

-- 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);
denylist را در دیتابیس نگذار — Redis با TTL

جدولِ SQL بالا برای فهم خوب است، اما در production یک jti denylist را در Redis با TTL (برابرِ زمانِ باقی‌مانده تا exp) بگذار، نه در دیتابیسِ رابطه‌ای. دلیل: این lookup در هر درخواست به resource server اجرا می‌شود؛ باید میکروثانیه‌ای و in-memory باشد و خودش پاک شود. یک کوئریِ SQL در هر درخواستِ API، سیستم را زانو می‌زند و جدول را با رکوردهای منقضی پر می‌کند. TTLِ Redis رکورد را دقیقاً وقتی توکن به‌هرحال منقضی می‌شود حذف می‌کند.


۷) مدیریتِ رازها — Vault، Sealed Secrets و الگوهای امن

توکن‌ها، کلیدها، client secretها، رمزِ دیتابیس — همه رازند. بزرگ‌ترین اشتباهِ ابتداییِ امنیت، سخت‌کدکردنِ راز در کد یا commit در Git است. یک راز که یک‌بار در تاریخچه‌ی Git رفت، برای همیشه آن‌جاست (حتی اگر بعداً پاکش کنی).

راز را در `application.yml` یا Docker image نگذار

یک راز در فایلِ 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
Sealed Secrets نقطه‌ی شروع خوبی است، نه مقصد

اگر تازه شروع کرده‌ای و GitOps می‌خواهی، Sealed Secrets ساده و کافی است: راز را رمزنگاری کن و با خیالِ راحت در Git بگذار. اما وقتی رشد کردی و به چرخشِ خودکار، رازِ پویا، و مدیریتِ مرکزیِ چند-cluster نیاز پیدا کردی، به External Secrets Operator با پشتِ Vault مهاجرت کن. جامعه در ۲۰۲۶ همین مسیر را توصیه می‌کند. نکته‌ی مهم: در هر دو حالت، هدفِ نهایی حذفِ رازِ ایستا از دستِ انسان است.

چرا Kubernetes Secret به‌تنهایی کافی نیست؟

چون 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، و در صورتِ نیاز jti denylist در 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.

Roadmap for this chapter
  • 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.
Reference versions

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

The airport

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.

The classic trap: mistaking authn for authz

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.

What is the difference between authentication and authorization in one sentence?

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.

The hotel valet key

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).
OIDC = an identity layer on top of OAuth2

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)
Implicit and password grants are dead

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
Senior judgment: keep scopes tight

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.

Why was PKCE added when we already have client_secret?

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 concert ticket with a hologram

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.
The payload is not encrypted — don't put secrets in it!

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;
}
How does a resource server validate a JWT without calling the AS on every request?

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 fatal trap: `alg: none`

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 golden pattern: short access token + rotating refresh token

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:

  1. jti denylist: keep revoked tokens' jti in Redis with a TTL equal to exp. Every resource server checks before accepting. Cost: one lookup per request — you're no longer fully stateless.
  2. token versioning: keep a version number on the user profile and put it in the claim; on block, bump the version.
  3. key rotation: on a wide breach, rotate the signing key so all old tokens die at once.
How do you revoke a JWT before it expires?

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.

What's the difference between an access token and a refresh token?

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
Authenticate at the edge, enforce in depth (defense in depth)

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/**
Propagation patterns: pass-through vs token exchange

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 aud must 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.
Should you relay the access token between all microservices?

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."

A two-way secret handshake

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.
Two identity layers: user and workload

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.
Does mTLS replace JWT?

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?"

An employee badge in a building

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."

A smart guard instead of a badge door

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.

A social relationship graph

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
Role explosion — the disease of pure RBAC

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.

Senior judgment: most real systems are hybrid

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?"

What's the difference between a scope and a role in OAuth/authz?

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 and side-effects

@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.

Don't scatter authz logic across controllers

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
}
OPA as an independent legal advisor

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
When do you use OPA/Rego and when Zanzibar/OpenFGA?

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.

Should policy live inside the application or in a separate service?

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)
);
Dialect differences that really matter in RBAC
  • BOOLEAN: PostgreSQL has a native BOOLEAN; Oracle didn't in SQL until 23ai and used NUMBER(1) with 0/1 (in SQL; PL/SQL always had BOOLEAN).
  • IDENTITY: both support GENERATED ALWAYS AS IDENTITY (Oracle since 12c); older Oracle used the SEQUENCE + TRIGGER pattern.
  • Empty string = NULL: in Oracle, '' equals NULL! So WHERE 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 (since VARCHAR behavior isn't guaranteed); in PostgreSQL VARCHAR.

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);
Don't put the denylist in the database — use Redis with TTL

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).

Don't put a secret in `application.yml` or a Docker image

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
Sealed Secrets is a good start, not the destination

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.

Why isn't a Kubernetes Secret alone enough?

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

What you learned
  • 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 (close alg=none and RS/HS confusion), and know revocation is hard — short access token + refresh rotation, and a jti denylist 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. @PreAuthorize to 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.