NGINX JWT protection – JWT 클레임 검증을 통한 요청 제어

API 보안의 중요성이 커지는 환경에서 NGINX JWT protection 은 단순한 인증을 넘어 정교한 접근 제어를 가능하게 하는 F5 WAF for NGINX의 기능입니다. F5 WAF for NGINX를 활용하면 JWT의 서명 유효성뿐만 아니라, 토큰 내부의 클레임(Claim) 값까지 검증하여 사용자 권한에 따른 빈틈없는 API 보안 정책을 구현할 수 있습니다.

이번 포스팅에서는 F5 WAF for NGINX를 통해 JWT 클레임 검증 정책을 구성하고, 실제 JWT 토큰을 사용한 요청을 통한 접근 허용/차단을 확인해보겠습니다.

목차

1. 환경 및 버전 정보
2. NGINX JWT protection 정책 구성
3. NGINX JWT protection 클레임 검증 확인
4. 결론

1. 환경 및 버전 정보

구성 요소버전
OSUbuntu 24.04.3
NGINX PlusR35
F5 WAF for NGINX5.9.0

2. NGINX JWT protection 정책 구성

JWT 검증 및 JWT의 클레임 검증을 위한 NGINX JWT protection 정책은 다음과 같이 구성할 수 있습니다.

{
    "policy": {
        "name": "jwt_claim_policy",
        "enforcementMode": "blocking",
        "template": {
            "name": "POLICY_TEMPLATE_NGINX_BASE"
        },
        "access-profiles": [
            {
                "name": "jwt_access_profile",
                "description": "JWT auth profile",
                "enforceMaximumLength": true,
                "enforceValidityPeriod": true,
                "keyFiles": [
                    {
                        "contents": "{\n  \"keys\": [\n    {\n      \"kid\": \"test-key\",\n      \"kty\": \"RSA\",\n      \"e\": \"AQAB\",\n      \"n\": \"uDvRCsLaEdC4kaNX0pANcSAde9VWcL03AmQfnjgsIkN_xOl598wvVcg_DJ9SlspjJBOjoZrnQ6HPl2xEcQRHhTXsa7e7Ac6RyRHFOsBvUQW1rJtUGMsls6QVA65NEBJa2GKQya8hFio_m2u1nm0smdUMRr3XJNvoQV9M75VuVhJyM2UZESDb9LUmoeckTwUGqwJnxjnGFv0yHIdJH1Thi4xi1VMtUIEX20Nczbw5QWkPjX3Id_pXpdqz4VP72D3iCR6j3pvjEMQOvLZUxC2qr5wczp0obuw_u--wD8YgUW7XeGFBHHX4MOAQV0MvGcczH42G-dfSYPO0J54I7sFT_Q\",\n      \"x5c\": [\"MIIC6jCCAdKgAwIBAgIGAZq9014CMA0GCSqGSIb3DQEBCwUAMDYxNDAyBgNVBAMMK0RhMllKbm9OMWZyZjVQVWxTN0plVkNoTlYyN3Y4TUxtSS1DUHBXY3VBck0wHhcNMjUxMTI2MDE0MjE3WhcNMjYwOTIyMDE0MjE3WjA2MTQwMgYDVQQDDCtEYTJZSm5vTjFmcmY1UFVsUzdKZVZDaE5WMjd2OE1MbUktQ1BwV2N1QXJNMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDvRCsLaEdC4kaNX0pANcSAde9VWcL03AmQfnjgsIkN/xOl598wvVcg/DJ9SlspjJBOjoZrnQ6HPl2xEcQRHhTXsa7e7Ac6RyRHFOsBvUQW1rJtUGMsls6QVA65NEBJa2GKQya8hFio/m2u1nm0smdUMRr3XJNvoQV9M75VuVhJyM2UZESDb9LUmoeckTwUGqwJnxjnGFv0yHIdJH1Thi4xi1VMtUIEX20Nczbw5QWkPjX3Id/pXpdqz4VP72D3iCR6j3pvjEMQOvLZUxC2qr5wczp0obuw/u++wD8YgUW7XeGFBHHX4MOAQV0MvGcczH42G+dfSYPO0J54I7sFT/QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBC+628QDmWUeAHc1kUlHTRsVmVvxBYCsIZWx56JlI5Boz23O4flG4xU8KLY5KlOJfCnV01VzJKyPe8GwpgSY2IJSjVgTLROvO8lbCF0bClNCN5wlwuuCfFZDwCXXQfZHYp97gkh0BI9J2wQ29Qu6UY2keLoPiqxOVt9Z6K94x7xzVoTaXt1W3PX0utOrKGsOztFzsUi9o0Hm6gpjtv22yZ0yk7zmEr9IuxIebCBRQyiTZ5/DOaA1EO4YbfD4iPDH3u3HTDRgUZMhWSZdZG9WcU1TfkvuVOVz+Me1dbu1nnDH0I1vz61LcorGoK3gQP64wLnR/RKJ3/eZBpYM18GsQj\"]\n    }\n  ]\n}\n",
                        "fileName": "JWKSFile.json"
                    }
                ],
                "location": {
                    "name": "authorization",
                    "in": "header"
                },
                "maximumLength": 2000,
                "type": "jwt",
                "usernameExtraction": {
                    "claimPropertyName": "sub",
                    "enabled": true,
                    "isMandatory": false
                },
                "verifyDigitalSignature": true
            }
        ],
        "applicationLanguage": "utf-8",
        "blocking-settings": {
            "violations": [
                {
                    "name": "VIOL_ACCESS_INVALID",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_MALFORMED",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_MISSING",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_UNAUTHORIZED",
                    "alarm": true,
                    "block": true
                }
            ]
        },
        "urls": [
            {
                "name": "/api/admin",
                "accessProfile": {
                    "name": "jwt_access_profile"
                },
                "attackSignaturesCheck": true,
                "authorizationRules": [
                    {
                        "name": "auth_role_user",
                        "condition": "claims['role'] == 'admin'"
                    }
                ],
                "isAllowed": true,
                "method": "*",
                "protocol": "http",
                "type": "explicit"
            },
            {
                "name": "/api/user",
                "accessProfile": {
                    "name": "jwt_access_profile"
                },
                "attackSignaturesCheck": true,
                "authorizationRules": [
                    {
                        "name": "auth_role_user",
                        "condition": "claims['role'] == 'user' or claims['role'] == 'admin'"
                    }
                ],
                "isAllowed": true,
                "method": "*",
                "protocol": "http",
                "type": "explicit"
            },
            {
                "name": "/api/premium",
                "accessProfile": {
                    "name": "jwt_access_profile"
                },
                "attackSignaturesCheck": true,
                "authorizationRules": [
                    {
                        "name": "subscription",
                        "condition": "claims['subscription'].contains('premium')"
                    }
                ],
                "isAllowed": true,
                "mandatoryBody": false,
                "method": "*",
                "protocol": "http",
                "type": "explicit"
            }
        ]
    }
}

정책 파일 구성을 부분별로 나눠 확인해보겠습니다.

JWT 검증 설정을 구성하는 access-profiles 입니다.
F5 WAF for NGINX 5.9 버전 기준 하나의 정책 파일에는 하나의 access-profile만 구성할 수 있습니다.

        "access-profiles": [
            {
                "name": "jwt_access_profile",
                "description": "JWT auth profile",
                "enforceMaximumLength": true,
                "enforceValidityPeriod": true,
                "keyFiles": [
                    {
                        "contents": "{\n  \"keys\": [\n    {\n      \"kid\": \"test-key\",\n      \"kty\": \"RSA\",\n      \"e\": \"AQAB\",\n      \"n\": \"uDvRCsLaEdC4kaNX0pANcSAde9VWcL03AmQfnjgsIkN_xOl598wvVcg_DJ9SlspjJBOjoZrnQ6HPl2xEcQRHhTXsa7e7Ac6RyRHFOsBvUQW1rJtUGMsls6QVA65NEBJa2GKQya8hFio_m2u1nm0smdUMRr3XJNvoQV9M75VuVhJyM2UZESDb9LUmoeckTwUGqwJnxjnGFv0yHIdJH1Thi4xi1VMtUIEX20Nczbw5QWkPjX3Id_pXpdqz4VP72D3iCR6j3pvjEMQOvLZUxC2qr5wczp0obuw_u--wD8YgUW7XeGFBHHX4MOAQV0MvGcczH42G-dfSYPO0J54I7sFT_Q\",\n      \"x5c\": [\"MIIC6jCCAdKgAwIBAgIGAZq9014CMA0GCSqGSIb3DQEBCwUAMDYxNDAyBgNVBAMMK0RhMllKbm9OMWZyZjVQVWxTN0plVkNoTlYyN3Y4TUxtSS1DUHBXY3VBck0wHhcNMjUxMTI2MDE0MjE3WhcNMjYwOTIyMDE0MjE3WjA2MTQwMgYDVQQDDCtEYTJZSm5vTjFmcmY1UFVsUzdKZVZDaE5WMjd2OE1MbUktQ1BwV2N1QXJNMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDvRCsLaEdC4kaNX0pANcSAde9VWcL03AmQfnjgsIkN/xOl598wvVcg/DJ9SlspjJBOjoZrnQ6HPl2xEcQRHhTXsa7e7Ac6RyRHFOsBvUQW1rJtUGMsls6QVA65NEBJa2GKQya8hFio/m2u1nm0smdUMRr3XJNvoQV9M75VuVhJyM2UZESDb9LUmoeckTwUGqwJnxjnGFv0yHIdJH1Thi4xi1VMtUIEX20Nczbw5QWkPjX3Id/pXpdqz4VP72D3iCR6j3pvjEMQOvLZUxC2qr5wczp0obuw/u++wD8YgUW7XeGFBHHX4MOAQV0MvGcczH42G+dfSYPO0J54I7sFT/QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBC+628QDmWUeAHc1kUlHTRsVmVvxBYCsIZWx56JlI5Boz23O4flG4xU8KLY5KlOJfCnV01VzJKyPe8GwpgSY2IJSjVgTLROvO8lbCF0bClNCN5wlwuuCfFZDwCXXQfZHYp97gkh0BI9J2wQ29Qu6UY2keLoPiqxOVt9Z6K94x7xzVoTaXt1W3PX0utOrKGsOztFzsUi9o0Hm6gpjtv22yZ0yk7zmEr9IuxIebCBRQyiTZ5/DOaA1EO4YbfD4iPDH3u3HTDRgUZMhWSZdZG9WcU1TfkvuVOVz+Me1dbu1nnDH0I1vz61LcorGoK3gQP64wLnR/RKJ3/eZBpYM18GsQj\"]\n    }\n  ]\n}\n",
                        "fileName": "JWKSFile.json"
                    }
                ],
                "location": {
                    "name": "authorization",
                    "in": "header"
                },
                "maximumLength": 2000,
                "type": "jwt",
                "usernameExtraction": {
                    "claimPropertyName": "sub",
                    "enabled": true,
                    "isMandatory": false
                },
                "verifyDigitalSignature": true
            }
        ],
  • enforceMaximumLength: 토큰의 최대 길이 검증 여부를 설정합니다. 하단의 maximumLength 값을 기반으로 검증합니다.
  • enforceValidityPeriod: 토큰의 유효 기간을 나타내는 nbf, exp 클레임의 검증 여부를 설정합니다.
  • keyFiles: JWT 검증에 사용할 JWKS 파일의 값을 설정합니다. JWKS 파일에는 최대 10개의 JWK를 구성할 수 있습니다.
  • location: 요청에서 토큰을 확인할 위치를 설정합니다. headerquery를 지원합니다.
  • usernameExtraction: 사용자 이름을 추출할 클레임 값을 설정합니다.
    • isMandatory: true로 설정 시 해당하는 클레임 값이 JWT에 없을 경우 해당 요청을 차단합니다.
  • verifyDigitalSignature: JWT의 서명 검증 여부를 설정합니다.

정책 위반 시 동작을 설정하는 blocking-settings입니다.

        "blocking-settings": {
            "violations": [
                {
                    "name": "VIOL_ACCESS_INVALID",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_MALFORMED",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_MISSING",
                    "alarm": true,
                    "block": true
                },
                {
                    "name": "VIOL_ACCESS_UNAUTHORIZED",
                    "alarm": true,
                    "block": true
                }
            ]
        },
  • VIOL_ACCESS_INVALID : JWT의 유효성 검증에 실패한 경우의 동작을 설정합니다.
  • VIOL_ACCESS_MALFORMED : JWT의 형식이 잘못된 경우의 동작을 설정합니다.
  • VIOL_ACCESS_MISSING : 요청에 토큰이 포함되지 않은 경우의 동작을 설정합니다.
  • VIOL_ACCESS_UNAUTHORIZED : JWT의 클레임 검증에 실패한 경우(권한이 없을 경우)의 동작을 설정합니다. 클레임 검증을 통한 차단 구성 시 필수적입니다.

정책을 적용할 url을 정의하는 설정입니다.

        "urls": [
            {
                "name": "/api/admin",
                "accessProfile": {
                    "name": "jwt_access_profile"
                },
                "attackSignaturesCheck": true,
                "authorizationRules": [
                    {
                        "name": "auth_role_user",
                        "condition": "claims['role'] == 'admin'"
                    }
                ],
                "isAllowed": true,
                "method": "*",
                "protocol": "http",
                "type": "explicit"
            }

......
  • name: url을 정의합니다
  • accessProfile: 참조할 accessProfile의 이름을 설정합니다.
  • attackSignaturesCheck: 공격 시그니처와 위협 캠페인의 탐지 여부를 설정합니다.
  • authorizationRules: JWT 클레임 검증 규칙을 정의합니다.
    • name: 고유한 사용자 지정 규칙 이름을 설정합니다.
    • condition: 접근 권한을 부여하기 위한 부울 표현식입니다.
  • type: url의 타입을 정의합니다. wildcard로 설정 시에만 name의 와일드카드 문자가 와일드카드로 해석됩니다.

각 엔드포인트별 클레임 검증 설정은 다음과 같이 적용했습니다.

  • /api/admin : role 클레임의 값이 admin일 경우 허용
  • /api/user : role 클레임의 값이 user 혹은 admin일 경우 허용
  • /api/premium: subscription 클레임의 값에 premium이 포함된 경우 허용

3. NGINX JWT protection 클레임 검증 확인

앞서 구성한 정책을 NGINX에 적용하고, JWT 토큰을 생성하여 클레임 검증 정책의 정상 동작 여부를 확인하겠습니다.

정책 적용 및 엔드포인트 구성을 위해 사용한 NGINX 설정 파일은 다음과 같습니다.

jwt.conf
upstream jwt-backend {
    server 127.0.0.1:8888;
}

server {
    listen 80;
    server_name jwt.example.com;

    access_log /var/log/nginx/jwt_test_access.log;
    error_log /var/log/nginx/jwt_test_error.log;

    proxy_http_version 1.1;

    # F5 WAF for NGINX 활성화 및 정책 설정
    app_protect_enable on;
    app_protect_policy_file /etc/app_protect/conf/jwt_claim_policy.json;

    # 로깅 설정
    app_protect_security_log_enable on;
    app_protect_security_log /etc/app_protect/conf/log_default.json /var/log/nginx/waf.log;

    location /api/admin {
        proxy_pass http://jwt-backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /api/user {
        proxy_pass http://jwt-backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /api/premium {
        proxy_pass http://jwt-backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        proxy_pass http://jwt-backend;
    }
}
jwt-backend.conf
server {
    listen 8888;
    server_name localhost;

    default_type application/json;

    location = /api/admin {

        return 200 '{
  "endpoint": "/api/admin",
  "message": "Admin access granted",
  "user": "$jwt_claim_sub",
  "role": "$jwt_claim_role",
  "timestamp": "$time_iso8601"
}\n';
    }

    location = /api/user {

        return 200 '{
  "endpoint": "/api/user",
  "message": "Access granted",
  "user": "$jwt_claim_sub",
  "role": "$jwt_claim_role",
  "timestamp": "$time_iso8601"
}\n';
    }

    location = /api/premium {

        return 200 '{
  "endpoint": "/api/premium",
  "message": "Premium content access granted",
  "user": "$jwt_claim_sub",
  "subscription": "$jwt_claim_subscription",
  "timestamp": "$time_iso8601"
}\n';
    }

    location / {
       root   /usr/share/nginx/html;
       index  index.html index.htm;
    }
}

role: admin, subscription: premium 클레임을 가진 JWT를 생성하고, 3개의 엔드포인트에 요청을 전송하여 확인합니다. JWT는 jwt.io에서 생성했습니다.

NGINX JWT protection - role 클레임 검증 성공
NGINX JWT protection - role 클레임 검증 성공
NGINX JWT protection - subscription 클레임 검증 성공

모든 엔드포인트의 클레임 검증에 성공하여 정상 응답을 수신합니다.

role: user, subscription: basic 클레임을 가진 JWT를 생성하고, 3개의 엔드포인트에 요청을 전송하여 확인합니다.

NGINX JWT protection - role 클레임 검증 실패, 차단
NGINX JWT protection - role 클레임 검증 성공
NGINX JWT protection - subscription 클레임 검증 실패, 차단

클레임 검증으로 인해 /api/user 엔드포인트를 제외한 요청은 차단됩니다.

role: unknwon 클레임을 가지고 subscription 클레임은 없는 JWT를 생성하고, 3개의 엔드포인트에 요청을 전송하여 확인합니다.

NGINX JWT protection - role 클레임 검증 실패, 차단
NGINX JWT protection - role 클레임 검증 실패, 차단
NGINX JWT protection - subscription 클레임 검증 실패, 차단

모든 엔드포인트의 클레임 검증 규칙을 통과하지 못해 모두 차단됩니다.

차단된 security log의 json_log 부분을 확인하면 다음과 같은 내용을 확인할 수 있습니다.
실제 로그과 동일한 형태가 아닌 가시성을 위해 정리한 형식입니다.

{
  "id": "14451805551388151866",
  "violations": [
    {
      "enforcementState": {
        "isBlocked": true,
        "isAlarmed": true,
        "isLearned": false,
        "attackType": [
          {
            "name": "Authentication/Authorization Attacks"
          }
        ]
      },
      "violation": {
        "name": "VIOL_ACCESS_UNAUTHORIZED"
      },
      "policyEntity": {
        "urls": [
          {
            "name": "/api/premium",
            "authorizationRules": [
              {
                "name": "subscription"
              }
            ]
          }
        ]
      },
      "observedEntity": {
        "claims": "{\"sub\":\"temp\",\"name\":\"John Doe\",\"role\":\"unknown\",\"iat\":1516239022,\"exp\":1900000000}"
      }
    }
  ]
}

클레임 검증 실패로 인한 VIOL_ACCESS_UNAUTHORIZED 와 URL에 적용된 authorizationRules, 요청에 포함된 토큰의 클레임 값이 로그에 기록되는 것을 확인할 수 있습니다.

4. 결론

이번 포스트에서는 F5 WAF for NGINX JWT Protection 정책 설정을 통해 JWT의 유효성을 검증하고, 더 나아가 role이나 subscription과 같은 특정 클레임 값을 기반으로 엔드포인트 접근을 제어하는 과정을 살펴보았습니다.

NGINX JWT protection 기능을 활용하여 다음과 같은 이점을 얻을 수 있습니다.

  1. 보안 계층의 강화: 애플리케이션 코드에 도달하기 전, NGINX 단계에서 비인가 요청을 사전에 차단함으로써 심층 방어 전략을 구현할 수 있습니다.
  2. 백엔드 부하 감소: 잘못된 권한을 가진 요청을 리버스 프록시 단에서 즉시 거부함으로써, 백엔드 서비스의 불필요한 리소스 소모를 방지합니다.
  3. 유연한 정책 관리: JSON 기반의 선언적 정책 파일을 통해 API 별로 상이한 권한 요구사항을 손쉽게 정의하고 운영할 수 있습니다.

애플리케이션 보안을 위한 JWT 인증, 클레임 검증 뿐만이 아니라 다양한 WAF 기능을 지원하는 F5 WAF for NGINX를 사용해 보고 싶으시다면 NGINX STORE를 통해 문의하여 무료로 NGINX One trial을 통해 F5 WAF for NGINX를 체험해 보세요.

NGINX STORE를 통한 솔루션 도입 및 기술지원 무료 상담 신청

* indicates required