> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# JWT 검증과 JWKS: Didit Authentication 토큰

> Didit Authentication 토큰 검증 방법: PKCE 코드 교환, ES256 액세스·ID 토큰 클레임, 앱별 디스커버리와 JWKS, 키 교체, 리프레시 토큰 재사용 감지.

> [!WARNING]
>
> 인증 기능은 새로 출시되었습니다. 이 페이지의 모든 호출은 **최종 사용자** 자격 증명을 사용하거나 아무 자격 증명도 사용하지 않습니다. 인증 API 키나 콘솔 token을 이 endpoint로 절대 보내지 마십시오.

각 애플리케이션은 고유한 OpenID Connect 발급자(issuer)입니다. 발급자는 애플리케이션의 로그인 출처입니다.

* 라이브 애플리케이션: `https://<name>.auth.didit.me`
* 샌드박스 애플리케이션: `https://<name>.sandbox.auth.didit.me`
* 또는 활성화된 후의 사용자 지정 도메인

라이브와 샌드박스는 별도의 발급자이며 키도 각각 다릅니다. `DIDIT_AUTH_ISSUER`에는 끝에 슬래시가 없는 발급자를, `DIDIT_APPLICATION_ID`에는 애플리케이션 ID를 설정하십시오. 토큰에는 애플리케이션 ID가 `aud`로 포함됩니다. 게시용 키에는 발급자가 인코딩되어 있습니다. `pk_live_` 또는 `pk_test_` 뒤의 값은 패딩이 없는 base64url로 인코딩된 발급자입니다.

> [!NOTE]
>
> 사용자 지정 도메인이 활성화되면 기본 호스트 대신 이 도메인이 발급자가 됩니다. 새 token의 `iss`에는 이 도메인이 표시되고, discovery의 `jwks_uri`도 이 도메인을 가리키며, 게시용 키도 변경됩니다. 기본 호스트로 발급된 액세스 token은 갱신되기 전까지 `GET /session/`에서 실패합니다. 도메인을 활성화할 때 `DIDIT_AUTH_ISSUER`를 업데이트하거나, 이전 token이 만료될 때까지(최대 5분) 두 발급자를 모두 허용하십시오.

이 페이지의 서버 endpoint(discovery, 키, token, 세션)는 [인증 API 참조](https://docs.didit.me/ko/authentication/api-reference/get-openid-configuration.md)에 있으며, [openapi-authentication.json](https://docs.didit.me/openapi-authentication.json)에서 생성됩니다. 호스팅 로그인 페이지는 여기에 포함되지 않습니다.

## 디스커버리 및 키

| 요청 | 결과 |
| - | - |
| `GET {issuer}/.well-known/openid-configuration` | `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri` 및 발급자가 지원하는 항목. |
| `GET {issuer}/.well-known/jwks.json` | `{"keys": [...]}`, 공개 서명 키. |

발급자는 다음을 지원합니다.

* 응답 유형 `code`, 응답 모드 `query`.
* 권한 부여 유형 `authorization_code` 및 `refresh_token`.
* PKCE 방식은 `S256`만 지원합니다. 클라이언트는 공개 클라이언트입니다. 클라이언트 시크릿은 없으며, `client_id`는 애플리케이션 ID입니다.
* 서명 알고리즘 `ES256`(P-256 키).
* 스코프 `openid`, `email`, `phone`, `profile`, `offline_access`.

userinfo, introspection, end-session endpoint는 없으며, `prompt=none`은 지원되지 않습니다. 세션을 읽고 확인하려면 [`GET /session/`](#세션-확인)을 사용하십시오.

### 캐싱과 교체

두 문서 모두 `Cache-Control: public, max-age=300`으로 응답하므로, 여러분과 공유 캐시는 5분 동안 이를 보관할 수 있습니다. IP 주소당 분당 1,200회, 시간당 12,000회의 요청을 허용합니다. 이 한도는 로그인 endpoint보다 훨씬 높습니다. 많은 백엔드 인스턴스가 보통 하나의 주소를 공유하기 때문입니다.

* 각 키에는 `kid`가 있으며, 새 키 ID는 `k_`로 시작합니다. 모든 token은 `kid` 헤더에 자신의 키를 명시합니다.
* 키는 30일마다 교체됩니다. Didit은 다음 키가 서명을 시작하기 하루 전에 공개하고, 이전 키는 서명을 중단한 뒤에도 하루 동안 계속 공개합니다. 따라서 5분 동안 캐시된 JWKS는 교체 시점에도 계속 검증에 사용할 수 있습니다.
* `kid`로 키를 캐시하십시오. token이 명시한 `kid`를 보유하고 있지 않으면 JWKS를 다시 가져오되, 최대 1분에 한 번만 가져오십시오. 그래도 키가 없으면 token을 거부하십시오.
* Didit이 노출된 키를 폐기하면 JWKS에서 해당 키를 즉시 제거하며, `GET /session/`은 그 키가 서명한 모든 token을 거부합니다. 대체 키는 하루 전 공개 없이 즉시 서명을 시작합니다. 최대 5분 전에 캐시된 JWKS는 여전히 폐기된 키를 허용하고 대체 키는 아직 알지 못합니다. 따라서 캐시가 갱신되기 전까지 새 token이 오프라인 검증에 실패할 수 있습니다.

## 로그인 시작

호스팅 페이지를 여십시오. 이 페이지는 `authorization_endpoint`입니다.

```text theme={null}
{issuer}/sign-in?redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256
```

| 매개변수 | 규칙 |
| - | - |
| `redirect_uri` | [등록된 리디렉션 URL](https://docs.didit.me/ko/authentication/overview.md#리디렉션-url) 중 하나와 정확히 일치해야 합니다. |
| `state` | 최대 512자입니다. 콜백에서 확인하십시오. |
| `code_challenge` | PKCE 검증자의 base64url SHA-256 값입니다. 정확히 43자입니다. |
| `code_challenge_method` | `S256`. |
| `client_id` | 선택 사항입니다. 애플리케이션 ID와 같아야 합니다. |
| `nonce` | 선택 사항입니다. ID token에 복사되므로 이 로그인에 바인딩할 수 있습니다. |
| `scope`, `login_hint`, `ui_locales`, `max_age`, `prompt` | 선택 사항입니다. `login_hint`는 이메일 필드만 미리 채웁니다. |

[빠른 시작 가이드](https://docs.didit.me/ko/authentication/quickstarts/javascript.md)에서 이 요청을 대신 구성해 줍니다.

## 코드 교환

콜백은 `code`와 `state`를 받습니다. `state`를 확인한 다음 60초 안에 코드를 교환하십시오. 각 코드는 한 번만 사용할 수 있습니다. 본문은 JSON 또는 폼 인코딩일 수 있습니다.

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_CALLBACK",
    "code_verifier": "YOUR_PKCE_VERIFIER",
    "redirect_uri": "https://app.example.com/auth/callback"
  }'
```

```json theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIs...",
  "id_token": "eyJhbGciOiJFUzI1NiIs...",
  "refresh_token": "N4yZTl6y24YKYq77X_SO...",
  "token_type": "Bearer",
  "expires_in": 299,
  "session_id": "c9abf6cc-2d64-4bf2-b31e-a4d09a070ab1"
}
```

`expires_in`은 액세스 token이 남은 유효 시간을 초 단위로 나타냅니다. 다중 세션을 끄면 이 교환으로 해당 사용자의 다른 세션이 종료됩니다.

| 응답 | 의미 |
| - | - |
| `400 {"error": "invalid_grant"}` | 코드를 알 수 없거나, 이미 사용되었거나, 만료되었습니다. PKCE 검증자 또는 `redirect_uri`가 일치하지 않거나, `redirect_uri`가 더 이상 등록되어 있지 않습니다. 사용자가 차단되었거나 이메일이 더 이상 인증되지 않았습니다. 또는 사용자가 사용한 로그인 방법을 끄셨습니다. |
| `400 {"error": "invalid_client"}` | `client_id`가 애플리케이션 ID와 일치하지 않습니다. |
| `400 {"error": "mfa_required"}` | 로그인이 MFA 정책을 충족하지 않았습니다. |
| `400 {"<field>": [...]}` | 필드가 누락되었거나 잘못되었습니다. 예를 들어 권한 부여 요청에 필수 필드가 없으면 `{"non_field_errors": ["invalid_request"]}`가 반환됩니다. |
| `400 {"<field>": "unknown_field"}` | 본문에 이 endpoint가 받지 않는 필드가 있습니다. 예를 들어 `client_secret`이 해당합니다. 공개 클라이언트는 이 값을 보내지 않습니다. |

## 액세스 토큰 클레임

액세스 토큰은 `alg: ES256`과 `typ: at+jwt` 헤더를 가진 JWT입니다. 유효 기간은 최대 5분이며, 세션의 남은 기간을 넘지 않습니다.

| 클레임 | 의미 |
| - | - |
| `iss` | 발급자입니다. |
| `aud` | 애플리케이션 ID입니다. |
| `client_id` | 애플리케이션 ID입니다. |
| `sub` | 이 애플리케이션에서의 사용자 신원 ID입니다. |
| `sid` | 세션 ID입니다. AI 에이전트의 토큰인 경우, 사용자가 에이전트에 부여한 접근 권한의 ID입니다. |
| `jti`, `iat`, `exp` | 토큰 ID, 발급 시각, 만료 시각입니다. |
| `vendor_data` | 사용자의 고정 참조 값이며, 신원 인증 및 거래 모니터링과 공유됩니다. |
| `amr` | 로그인 방식입니다(예: `["passkey"]`). |
| `org` | 애플리케이션에서 사용자가 가장 먼저 가입한 조직 멤버십의 `{"id", "role"}`입니다. 멤버십이 없거나 조직 기능이 꺼져 있으면 `null`입니다. `GET /session/`은 모든 멤버십을 나열합니다. |
| `verified` | 검증 수준과 선택한 클레임이며, 없으면 `{}`입니다. 세션 중 승인된 검사 결과는 다음 토큰 갱신 때 토큰에 반영됩니다. |
| `act` | 사용자 본인이면 `null`입니다. 사용자를 대신하는 AI 에이전트이면 `{"sub", "agent", "scopes"}`입니다. [5단계](#서버에서-토큰-검증)를 참조하십시오. |

ID 토큰의 헤더는 `typ: JWT`입니다. 액세스 토큰과 같은 클레임을 담지만 `client_id`는 제외합니다. 대신 `auth_time`과 `email_verified`를 포함하며, 값이 있으면 `email`, `name`, `phone_number`도 포함합니다. 코드 교환으로 받은 ID 토큰에만 `nonce`가 들어 있으며, 이는 로그인 요청에 값이 있었을 때에만 해당합니다. 리프레시로 받은 ID 토큰에는 이것이 절대 들어 있지 않으므로, 확인은 코드 교환 시점의 ID 토큰에서 하십시오. ID 토큰을 액세스 토큰으로 받아들이면 안 됩니다.

## 서버에서 토큰 검증

클레임을 신뢰하기 전에 다음을 모두 확인해야 합니다.

1. `alg`는 `ES256`이고 `typ`는 `at+jwt`여야 합니다. `none`을 포함해 그 밖의 알고리즘은 모두 거부합니다.
2. `kid`가 애플리케이션의 JWKS에 있어야 하고, 서명이 검증되어야 합니다.
3. `iss`가 설정된 발급자와 같아야 하고, `aud`가 애플리케이션 ID와 같아야 합니다.
4. `exp`와 `iat`이 유효해야 합니다. 시계 차이는 최대 60초까지 허용합니다. `sub`, `sid`, `jti`가 있어야 합니다.
5. `act`는 `null`이어야 합니다. AI 에이전트의 액세스 토큰도 같은 키로 서명되며 같은 `typ`, `iss`, `aud`를 갖습니다. 이를 구분하는 것은 `act`뿐입니다. 엔드포인트가 에이전트를 허용하지 않으면 해당 토큰을 거부합니다. 허용하는 경우에는 `act.scopes`가 부여한 범위만 허용합니다. `GET /session/`은 에이전트 토큰을 항상 거부합니다.

발급자는 토큰이 아니라 설정에서 가져와야 합니다.

```javascript Node.js theme={null}
// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = process.env.DIDIT_AUTH_ISSUER; // https://<name>.auth.didit.me
const AUDIENCE = process.env.DIDIT_APPLICATION_ID;
// Cache keys for 5 minutes; refetch an unknown kid at most once a minute.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
  cooldownDuration: 60 * 1000,
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["ES256"],
    typ: "at+jwt",
    clockTolerance: 60,
    maxTokenAge: "5m", // also rejects an iat in the future
    requiredClaims: ["sub", "sid", "jti", "exp", "iat"],
  });
  if (payload.act !== null) throw new Error("agent tokens are not accepted");
  return payload; // payload.vendor_data is the person across Didit products
}
```

```python Python theme={null}
# pip install "pyjwt[crypto]"
import os
import threading
import time

import jwt

ISSUER = os.environ["DIDIT_AUTH_ISSUER"]  # https://<name>.auth.didit.me
AUDIENCE = os.environ["DIDIT_APPLICATION_ID"]
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json", lifespan=300)
refresh_lock = threading.Lock()
last_refresh = 0.0


def signing_key(kid: str | None) -> jwt.PyJWK:
    # PyJWKClient refetches for every unknown kid: limit it to once a minute.
    global last_refresh
    keys = {key.key_id: key for key in jwks.get_signing_keys()}
    if kid not in keys:
        with refresh_lock:
            if time.monotonic() - last_refresh >= 60:
                last_refresh = time.monotonic()
                fresh = jwks.get_signing_keys(refresh=True)
                keys = {key.key_id: key for key in fresh}
    if kid not in keys:
        raise jwt.InvalidTokenError("unknown signing key")
    return keys[kid]


def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("not an access token")
    claims = jwt.decode(
        token,
        signing_key(header.get("kid")).key,
        algorithms=["ES256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=60,
        options={"require": ["iss", "aud", "sub", "sid", "jti", "exp", "iat"]},
    )
    if "act" not in claims or claims["act"] is not None:
        raise jwt.InvalidTokenError("agent tokens are not accepted")
    return claims
```

오프라인 검증으로는 방금 종료된 세션을 확인할 수 없습니다. 이것이 문제가 되는 경우, 예를 들어 지급 처리 전에는 `GET /session/`을 호출하십시오.

## 세션 확인

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/session/" \
  -H "Authorization: Bearer $DIDIT_ACCESS_TOKEN"
```

응답에는 `sub`, `sid`, `application_id`, `vendor_data`, `amr`, `mfa_verified`, `email`, `email_verified`, `phone`, `verified_identity`, `verified`, `expires_at`, `post_logout_redirect_uri`, `organizations`가 포함됩니다. 각 조직 항목에는 `organization_id`, `organization__name`, `role`, `custom_role`이 있습니다.

이 확인은 오프라인 검증보다 엄격합니다. 세션이 취소되었거나, 유휴 상태가 되었거나, 유효 기간이 지났거나, 사용자가 차단되었거나, 이메일 인증이 더 이상 유효하지 않거나, 세션이 더 이상 MFA 정책을 충족하지 않거나(예: 사용자가 인증 앱을 추가했거나 조직 관리자가 된 경우), 토큰이 리프레시로 교체된 경우에도 실패합니다. 실패하면 `WWW-Authenticate: Bearer` 헤더와 함께 `401 {"detail": "invalid_token"}`을 반환합니다.

`GET /session/`은 IP 주소당 분당 30회, 시간당 300회까지 요청할 수 있으며, 이 한도는 `DELETE /session/`과 공유됩니다. 토큰은 매 요청마다 오프라인으로 검증하고, 이 호출은 꼭 필요한 순간에만 사용하십시오. 성공한 호출은 활동으로 집계되므로 세션의 `idle_timeout`이 다시 시작됩니다.

이메일 인증은 신원 인증이 아닙니다. 신원 인증 여부는 `verified`로 확인합니다. 신원 인증이 없으면 여기서는 `null`이지만, 토큰에는 `{}`가 들어 있습니다.

## 리프레시 토큰

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{"grant_type": "refresh_token", "refresh_token": "YOUR_REFRESH_TOKEN"}'
```

리프레시하면 새 액세스 토큰, ID 토큰, 리프레시 토큰이 발급되며, 보낸 리프레시 토큰은 더 이상 작동하지 않습니다.

* 리프레시 토큰은 무작위 문자열이며, 세션이 끝날 때까지 유효합니다.
* **재사용하면 세션이 종료됩니다.** 이미 사용된 리프레시 토큰이 다시 들어오면 Didit은 이를 토큰 도용의 신호로 보고 세션을 취소하며 `400 {"error": "invalid_grant"}`를 반환합니다. 세션의 토큰은 모두 작동을 멈춥니다. 이전 리프레시로 받은 토큰도 포함됩니다. 이들의 리프레시 토큰은 실패하고, 액세스 토큰은 `GET /session/`에서 즉시 실패합니다. 다만 오프라인 검증은 만료될 때까지, 최대 5분 동안 여전히 통과합니다.
* 세션마다 한 번에 하나의 리프레시만 실행하십시오. 탭, 기기, 서버 인스턴스가 여러 개여도 마찬가지입니다. 세 가지 토큰은 함께 교체해야 합니다.
* 리프레시가 `400`으로 실패하거나 응답이 없으면 다시 로그인을 시작하십시오. 무조건 재시도하지 마십시오. 리프레시가 실제로 성공한 뒤의 재시도는 재사용이 됩니다.
* `503 {"error": "temporarily_unavailable"}`는 리프레시가 수행되지 않았다는 뜻이며, 리프레시 토큰은 여전히 유효합니다. 백오프를 두고 재시도하십시오.
* Didit은 리프레시를 실행하기 전에 `429`를 반환합니다. 따라서 `Retry-After`만큼 기다린 뒤 재시도하십시오. 토큰 엔드포인트는 IP 주소당 분당 30회, 시간당 300회까지 허용합니다. 이에 더해, 애플리케이션의 모든 `POST`와 `DELETE`는 봇 방지 버킷을 공유합니다. 이 엔드포인트들을 합쳐 IP 주소당 분당 60회, 기기당 분당 30회입니다. 애플리케이션 전체로는 리프레시에 분당 6,000회 버킷이 있고, 그 밖의 모든 `POST`와 `DELETE`(브라우저와 로그인 단계 포함)에는 분당 3,000회 버킷이 있습니다. 따라서 리프레시가 로그인 한도를 소진하지 않습니다.
* 액세스 토큰이 5분이면, 시간당 300회의 리프레시로 활동 중인 사용자 약 25명을 감당할 수 있습니다. 서버 한 곳의 IP 주소에서 많은 사용자를 리프레시한다면, 요청에 새 토큰이 필요할 때에만 리프레시하고, 가능하면 브라우저나 앱에서 리프레시하십시오.

## 로그아웃

현재 사용자의 액세스 토큰과 함께 `DELETE /session/`을 호출하면 세션이 종료되고 `204`가 반환됩니다. 해당 리프레시 토큰은 더 이상 작동하지 않으며, 액세스 토큰은 즉시 `GET /session/`에서 실패합니다. 오프라인으로 검증한 토큰은 만료될 때까지 계속 유효합니다. 그런 다음 `post_logout_redirect_uri`를 설정했다면 사용자를 해당 주소로 이동시킵니다.

로그아웃에는 `GET /session/`이 허용하는 액세스 토큰이 필요합니다. 즉, 만료되지 않았고 리프레시로 교체되지 않은 토큰이어야 합니다. `401`이 반환되면 세션이 종료되지 않은 것입니다. 이 경우 토큰을 새로 고친 뒤 새 액세스 토큰으로 로그아웃하십시오. 리프레시가 `400`으로 실패하면 해당 세션은 더 이상 사용할 수 없습니다.

다음 경우에도 세션이 종료됩니다.

* `session_lifetime`(기본값 30일) 또는 `idle_timeout`(기본값 1일)을 초과한 경우,
* 여러 세션 허용이 꺼진 상태에서 사용자가 다시 로그인한 경우,
* `revoke_on_password_change`가 켜져 있는 상태에서 사용자가 비밀번호를 재설정한 경우,
* 귀하의 팀이 콘솔에서 세션을 취소하거나 사용자를 차단한 경우,
* 이미 사용된 리프레시 토큰이 다시 전송된 경우.

## 수명

| 항목 | 수명 |
| - | - |
| 로그인 트랜잭션 (`POST /authorize/`) | 10분 |
| 인증 코드 | 60초, 1회만 사용 |
| 액세스 토큰 | 최대 5분 |
| ID 토큰 | 함께 발급된 액세스 토큰과 동일한 `exp` |
| 리프레시 토큰 | 세션이 종료될 때까지, 1회만 사용 |
| 세션 | `session_lifetime` 및 `idle_timeout` |
| 스텝업 | 30분 |
| 디스커버리 및 JWKS 캐시 | 5분 |

## 목적에 따른 자격 증명 저장

* 인증 API 키와 콘솔 토큰은 서버에 보관하십시오.
* 브라우저 액세스 토큰은 가능한 한 메모리에 보관하십시오. 서버 렌더링 앱의 경우 보안 HttpOnly 쿠키를 사용하고, 쿠키로 인증된 요청은 CSRF에 대비하여 보호하십시오.
* 모바일 리프레시 토큰은 플랫폼이 보호하는 저장소에 보관하십시오. 로그아웃 후에는 로컬 상태를 삭제하십시오.
* 토큰을 URL이나 로그에 절대 포함하지 마십시오.

다음으로 [JavaScript 퀵스타트](https://docs.didit.me/ko/authentication/quickstarts/javascript.md), [Next.js 퀵스타트](https://docs.didit.me/ko/authentication/quickstarts/nextjs.md) 또는 [인증 API 레퍼런스](https://docs.didit.me/ko/authentication/api-reference/get-openid-configuration.md)를 진행하십시오.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.