2026-05-31·5 min read·sota.io Team

SD-JWT Credential Format for eIDAS 2.0 EUDIW: Complete Selective Disclosure Implementation Guide 2026

Post #3 in the sota.io EU Digital Identity & eIDAS 2.0 Developer Series

SD-JWT selective disclosure credential format for eIDAS 2.0 EUDIW implementation guide 2026

Post #1 covered the EUDIW architecture and Relying Party integration. Post #2 walked through the OpenID4VP cross-device flow. This post focuses on the actual credential payload: SD-JWT VC, the format the EU Digital Identity Wallet uses to represent and share identity attributes.

Understanding SD-JWT is non-negotiable if you're building a Relying Party. When you receive a VP Token from a wallet, the verifiable_credential inside it is an SD-JWT. You need to parse it, verify the issuer signature, reconstruct the disclosed claims, and check the holder binding — all before trusting any identity attribute.

This guide walks through the full format, the cryptographic mechanics, and complete TypeScript implementations for all three roles: issuer, holder, and verifier.


What Is SD-JWT?

SD-JWT (Selective Disclosure JWT) is an IETF specification (draft-ietf-oauth-selective-disclosure-jwt) that extends JSON Web Tokens to enable selective disclosure: the holder of a credential can reveal only a chosen subset of attributes to a verifier, without exposing the rest — and without the issuer's involvement at presentation time.

The classic JWT problem: if an issuer puts { "name": "Alice", "age": 34, "address": "Berlin", "salary": 85000 } into a signed JWT, Alice must hand over the entire blob to any verifier. She cannot prove her name without also revealing her salary.

SD-JWT solves this with a hash-based commitment scheme:

  1. The issuer replaces sensitive claims with hash commitments in the JWT body
  2. The raw claim values are packed into separate disclosure objects (short, base64url-encoded blobs)
  3. The holder receives the full set of disclosures but only presents the subset needed for each transaction
  4. The verifier receives the JWT body plus the selected disclosures, verifies the issuer signature over the body, then reconstructs only the disclosed claims

The issuer's signature covers only the hashes — never the raw values. This gives the holder genuine selective disclosure without requiring per-request issuer participation.


SD-JWT Structure Anatomy

A complete SD-JWT presentation looks like this:

<Issuer-signed JWT>~<Disclosure 1>~<Disclosure 2>~...<Disclosure N>~[KB-JWT]

The ~ character (tilde) is the separator. The trailing [KB-JWT] is an optional Key Binding JWT used to bind the presentation to the holder's key and prevent replay attacks. EUDIW requires key binding — you will always see a KB-JWT at the end.

Issuer-Signed JWT

The JWT body (decoded) looks like:

{
  "iss": "https://pid.eudi.de/issuer",
  "iat": 1748649600,
  "exp": 1780185600,
  "vct": "https://credentials.eudi.eu/PersonalIdentityData/1.0",
  "cnf": {
    "jwk": {
      "kty": "EC",
      "crv": "P-256",
      "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
      "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
    }
  },
  "_sd_alg": "sha-256",
  "_sd": [
    "hTMo6TFtCM5zXHmh1uXQNWAhcLFSwKb3wPQVVAI7xVA",
    "Z3Bpb25lZVBldGVyc29u2K3t-E0C1VrCEWRbkF9tMHQ",
    "VtFSmfBCPMwq4HxHiKrg-PZlxuJn5pFN3Vq7VvpxaXc",
    "9XLBnUCZ_gFlT3JMeWsB4J4mJG6QJb9lR5bKpZoIqH4",
    "Jq2K-yPJHk6HkDo0gQZ8vPaMHn6HBX1NLmhTlVcUFn0"
  ],
  "status": {
    "status_list": {
      "idx": 4723,
      "uri": "https://pid.eudi.de/statuslist/1"
    }
  }
}

Key fields:

Claims that are not in _sd are disclosed to all verifiers unconditionally. Claims that are in _sd require the matching disclosure object to be revealed.

Disclosure Objects

Each disclosure is a compact representation of a single claim. The raw structure before encoding:

["salt_value", "claim_name", claim_value]

Where:

Example for the given_name claim:

// Raw disclosure array
const disclosure = [
  "eluV5Og3gSNII8EYnsxA_A", // random salt
  "given_name",
  "Alice"
]

// Encode to base64url
const encoded = base64url(JSON.stringify(disclosure))
// → "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0"

// SHA-256 hash → this goes in the JWT's _sd array
const hash = sha256(encoded)
// → "hTMo6TFtCM5zXHmh1uXQNWAhcLFSwKb3wPQVVAI7xVA"

The issuer puts the hash in the JWT body; the raw disclosure travels separately. The verifier reconstructs sha256(disclosure) == hash_in_jwt to confirm authenticity.

Key Binding JWT (KB-JWT)

The KB-JWT proves that the current SD-JWT presentation was created by the legitimate holder — not replayed from a previously stolen presentation. It is a regular JWT signed with the holder's private key (corresponding to the cnf.jwk in the issuer JWT).

{
  "typ": "kb+jwt",
  "alg": "ES256"
}
{
  "iat": 1748735400,
  "aud": "https://rp.example.eu",
  "nonce": "n-0S6_WzA2Mj",
  "sd_hash": "X9bFHfZ7MXVLtVKPNkrMH8MuKHF-pTEV2noBzF1YXMU"
}

The sd_hash field is the SHA-256 hash of <Issuer-JWT>~<Disclosure1>~...~<DisclosureN>~ (everything up to but not including the KB-JWT itself). This binds the KB-JWT to the specific set of disclosures being presented.

The nonce is provided by your Relying Party in the Authorization Request — it ensures the KB-JWT is fresh and targeted at your specific request.


EUDIW Credential Types

The EU Digital Identity Wallet supports multiple credential types defined in the Architecture Reference Framework (ARF v2.4.0). All use SD-JWT VC format.

Personal Identity Data (PID)

The PID is the core identity credential, issued by national identity providers (e.g., Bundesdruckerei in Germany, DXC in the Netherlands).

{
  "vct": "https://credentials.eudi.eu/PersonalIdentityData/1.0",
  "claims": {
    "family_name": "Müller",
    "given_name": "Klaus",
    "birth_date": "1985-03-12",
    "birth_place": "Hamburg",
    "resident_address": {
      "street_address": "Hauptstraße 23",
      "locality": "Hamburg",
      "postal_code": "20095",
      "country": "DE"
    },
    "nationality": ["DE"],
    "personal_identifier": "DE/XX/12345678-A",
    "issuing_country": "DE",
    "issuing_authority": "Bundesdruckerei GmbH",
    "document_number": "T220001293",
    "issuance_date": "2024-06-01",
    "expiry_date": "2034-05-31",
    "portrait": "<base64-encoded JPEG>",
    "age_over_18": true,
    "age_over_21": true,
    "age_in_years": 41,
    "age_birth_year": 1985
  }
}

All claims except issuing_country and personal_identifier are selectively disclosable. Typical Relying Party use cases:

Mobile Driving Licence (mDL)

Based on ISO 18013-5 but wrapped in SD-JWT for the EUDIW ecosystem.

{
  "vct": "https://credentials.eudi.eu/mDL/1.0",
  "claims": {
    "document_number": "DL-DE-123456",
    "driving_privileges": [
      {
        "vehicle_category_code": "B",
        "issue_date": "2010-07-15",
        "expiry_date": "2030-07-15"
      }
    ],
    "un_distinguishing_sign": "D",
    "issue_date": "2020-07-15",
    "expiry_date": "2030-07-15",
    "sex": 1
  }
}

Electronic Attribute Attestations (EAA)

EAAs are domain-specific credentials issued by qualified trust service providers. Examples: professional licences (lawyer, doctor, architect), student IDs, loyalty cards, employee credentials.

{
  "vct": "https://credentials.attorney-bar.de/ProfessionalLicence/1.0",
  "claims": {
    "profession": "Rechtsanwalt",
    "registration_number": "RAK-BE-45678",
    "registration_body": "Rechtsanwaltskammer Berlin",
    "valid_from": "2008-01-15",
    "valid_until": "2027-12-31",
    "jurisdictions": ["DE"]
  }
}

TypeScript Implementation: Issuer Side

Here's a complete TypeScript issuer that creates SD-JWT PID credentials:

import { createHash, randomBytes } from 'crypto'
import { SignJWT, importJWK, JWK } from 'jose'

interface DisclosureMap {
  [claimName: string]: {
    salt: string
    value: unknown
    hash: string
    encoded: string
  }
}

function base64url(input: string | Buffer): string {
  const buf = typeof input === 'string' ? Buffer.from(input) : input
  return buf.toString('base64url')
}

function sha256(input: string): string {
  return createHash('sha256').update(input).digest('base64url')
}

function createDisclosure(claimName: string, value: unknown): {
  hash: string
  encoded: string
  salt: string
} {
  const salt = base64url(randomBytes(16))
  const disclosureArray = [salt, claimName, value]
  const encoded = base64url(JSON.stringify(disclosureArray))
  const hash = sha256(encoded)
  return { hash, encoded, salt }
}

interface PIDClaims {
  family_name: string
  given_name: string
  birth_date: string
  birth_place?: string
  resident_address?: object
  nationality?: string[]
  personal_identifier: string
  issuing_country: string
  issuing_authority: string
  document_number: string
  issuance_date: string
  expiry_date: string
  age_over_18?: boolean
  age_in_years?: number
}

async function issuePIDCredential(
  claims: PIDClaims,
  holderPublicKey: JWK,
  issuerPrivateKey: JWK,
  issuerKeyId: string
): Promise<string> {
  // Always-disclosed claims (not selective)
  const alwaysDisclosed = ['issuing_country', 'personal_identifier']

  // Build selective disclosures for all other claims
  const selectiveClaimNames = Object.keys(claims).filter(
    (k) => !alwaysDisclosed.includes(k)
  )

  const disclosures: DisclosureMap = {}
  const sdHashes: string[] = []

  for (const claimName of selectiveClaimNames) {
    const value = (claims as Record<string, unknown>)[claimName]
    if (value === undefined) continue
    const d = createDisclosure(claimName, value)
    disclosures[claimName] = { ...d, value }
    sdHashes.push(d.hash)
  }

  // JWT body: only always-disclosed claims + _sd array
  const jwtPayload = {
    iss: 'https://pid.eudi.de/issuer',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60, // 1 year
    vct: 'https://credentials.eudi.eu/PersonalIdentityData/1.0',
    cnf: { jwk: holderPublicKey },
    _sd_alg: 'sha-256',
    _sd: sdHashes,
    issuing_country: claims.issuing_country,
    personal_identifier: claims.personal_identifier,
    status: {
      status_list: {
        idx: Math.floor(Math.random() * 100000),
        uri: 'https://pid.eudi.de/statuslist/1',
      },
    },
  }

  const issuerKey = await importJWK(issuerPrivateKey, 'ES256')

  const issuerJWT = await new SignJWT(jwtPayload)
    .setProtectedHeader({
      alg: 'ES256',
      typ: 'vc+sd-jwt',
      kid: issuerKeyId,
    })
    .sign(issuerKey)

  // Assemble: <JWT>~<disc1>~<disc2>~...
  const disclosureEncodings = Object.values(disclosures).map((d) => d.encoded)
  const sdJwt = [issuerJWT, ...disclosureEncodings, ''].join('~')

  return sdJwt
}

TypeScript Implementation: Holder Side (Selective Disclosure)

The holder receives the full SD-JWT from the issuer, then creates a presentation by selecting which disclosures to include:

import { SignJWT, importJWK, decodeJwt, JWK } from 'jose'

interface ParsedSDJWT {
  issuerJWT: string
  disclosures: Map<string, string> // claimName → encoded disclosure
  kbJwt: string | null
}

function parseSDJWT(sdJwt: string): ParsedSDJWT {
  const parts = sdJwt.split('~')
  const issuerJWT = parts[0]

  // Last element is either empty string or KB-JWT
  const hasKbJwt = parts[parts.length - 1] !== ''
  const kbJwt = hasKbJwt ? parts[parts.length - 1] : null
  const rawDisclosures = hasKbJwt ? parts.slice(1, -1) : parts.slice(1, -1)

  const disclosures = new Map<string, string>()
  for (const encoded of rawDisclosures) {
    if (!encoded) continue
    const decoded = JSON.parse(
      Buffer.from(encoded, 'base64url').toString('utf8')
    )
    // decoded = [salt, claimName, value]
    const claimName = decoded[1] as string
    disclosures.set(claimName, encoded)
  }

  return { issuerJWT, disclosures, kbJwt }
}

async function createPresentation(
  sdJwt: string,
  claimsToDisclose: string[],
  holderPrivateKey: JWK,
  rpAudience: string,
  rpNonce: string
): Promise<string> {
  const { issuerJWT, disclosures } = parseSDJWT(sdJwt)

  // Select only the requested disclosures
  const selectedDisclosures: string[] = []
  for (const claimName of claimsToDisclose) {
    const encoded = disclosures.get(claimName)
    if (encoded) {
      selectedDisclosures.push(encoded)
    }
  }

  // Build the presentation body (before KB-JWT)
  const presentationBody = [issuerJWT, ...selectedDisclosures, ''].join('~')

  // Compute sd_hash over presentation body
  const sdHash = createHash('sha256')
    .update(presentationBody)
    .digest('base64url')

  // Create Key Binding JWT
  const holderKey = await importJWK(holderPrivateKey, 'ES256')
  const kbJwt = await new SignJWT({
    sd_hash: sdHash,
    aud: rpAudience,
    nonce: rpNonce,
    iat: Math.floor(Date.now() / 1000),
  })
    .setProtectedHeader({ alg: 'ES256', typ: 'kb+jwt' })
    .sign(holderKey)

  // Final presentation: body + KB-JWT
  return presentationBody + kbJwt
}

// Example: holder discloses only name + age for a service that needs age verification
const presentation = await createPresentation(
  storedSDJWT,
  ['given_name', 'family_name', 'age_over_18'],
  holderPrivateKey,
  'https://rp.example.eu',
  rpNonce
)

TypeScript Implementation: Verifier Side

The verifier receives the presentation and must perform a multi-step verification:

import { jwtVerify, createRemoteJWKSet, importJWK } from 'jose'
import { createHash } from 'crypto'

interface VerifiedCredential {
  issuer: string
  credentialType: string
  holderPublicKey: JWK
  claims: Record<string, unknown>
  issuedAt: Date
  expiresAt: Date
  isExpired: boolean
  isRevoked: boolean
}

async function verifySDJWTPresentation(
  presentation: string,
  expectedAudience: string,
  expectedNonce: string,
  issuerTrustList: Map<string, URL> // issuer → JWKS endpoint
): Promise<VerifiedCredential> {
  // 1. Split presentation into components
  const parts = presentation.split('~')
  const issuerJWT = parts[0]
  const kbJwt = parts[parts.length - 1]

  if (!kbJwt) {
    throw new Error('Missing Key Binding JWT — holder binding required')
  }

  const rawDisclosures = parts.slice(1, -1).filter(Boolean)

  // 2. Decode issuer JWT header to get issuer and key ID
  const issuerHeader = JSON.parse(
    Buffer.from(issuerJWT.split('.')[0], 'base64url').toString()
  )
  const issuerPayloadRaw = JSON.parse(
    Buffer.from(issuerJWT.split('.')[1], 'base64url').toString()
  )
  const issuer: string = issuerPayloadRaw.iss

  // 3. Verify issuer JWT signature using EUDIW trust framework
  const jwksUrl = issuerTrustList.get(issuer)
  if (!jwksUrl) {
    throw new Error(`Issuer ${issuer} not in trust list`)
  }

  const JWKS = createRemoteJWKSet(jwksUrl)
  const { payload: issuerPayload } = await jwtVerify(issuerJWT, JWKS, {
    issuer,
    clockTolerance: 60,
  })

  // 4. Verify Key Binding JWT
  const holderJWK = (issuerPayload.cnf as { jwk: JWK }).jwk
  const holderPublicKey = await importJWK(holderJWK, 'ES256')

  const { payload: kbPayload } = await jwtVerify(kbJwt, holderPublicKey, {
    audience: expectedAudience,
    clockTolerance: 60,
  })

  // 5. Verify nonce (prevents replay)
  if (kbPayload.nonce !== expectedNonce) {
    throw new Error('Nonce mismatch — possible replay attack')
  }

  // 6. Verify sd_hash binds KB-JWT to this exact presentation
  const presentationBody = parts.slice(0, -1).join('~') + '~'
  const expectedSdHash = createHash('sha256')
    .update(presentationBody)
    .digest('base64url')

  if (kbPayload.sd_hash !== expectedSdHash) {
    throw new Error('sd_hash mismatch — presentation tampering detected')
  }

  // 7. Reconstruct disclosed claims from disclosures
  const sdHashes = (issuerPayload._sd as string[]) || []
  const claims: Record<string, unknown> = {}

  // Add always-disclosed claims
  const reservedKeys = ['iss', 'iat', 'exp', 'vct', 'cnf', '_sd_alg', '_sd', 'status']
  for (const [key, value] of Object.entries(issuerPayload)) {
    if (!reservedKeys.includes(key)) {
      claims[key] = value
    }
  }

  // Add selectively disclosed claims
  for (const encoded of rawDisclosures) {
    const decoded = JSON.parse(
      Buffer.from(encoded, 'base64url').toString('utf8')
    )
    const [salt, claimName, value] = decoded

    // Verify this disclosure was committed to by the issuer
    const hash = createHash('sha256').update(encoded).digest('base64url')
    if (!sdHashes.includes(hash)) {
      throw new Error(
        `Disclosure for ${claimName} not found in issuer's _sd array`
      )
    }

    claims[claimName] = value
  }

  // 8. Check credential expiry
  const exp = issuerPayload.exp as number
  const isExpired = exp < Math.floor(Date.now() / 1000)

  // 9. Check revocation (status list)
  const statusInfo = issuerPayload.status as {
    status_list: { idx: number; uri: string }
  }
  const isRevoked = await checkStatusList(
    statusInfo.status_list.uri,
    statusInfo.status_list.idx
  )

  return {
    issuer: issuerPayload.iss as string,
    credentialType: issuerPayload.vct as string,
    holderPublicKey: holderJWK,
    claims,
    issuedAt: new Date((issuerPayload.iat as number) * 1000),
    expiresAt: new Date(exp * 1000),
    isExpired,
    isRevoked,
  }
}

async function checkStatusList(
  statusListUri: string,
  idx: number
): Promise<boolean> {
  // RFC 9449 Token Status List implementation
  const response = await fetch(statusListUri, {
    headers: { Accept: 'application/statuslist+jwt' },
  })
  if (!response.ok) return false

  const statusJwt = await response.text()
  // Decode and check the bit at position idx
  const payload = JSON.parse(
    Buffer.from(statusJwt.split('.')[1], 'base64url').toString()
  )

  const statusListBytes = Buffer.from(payload.status_list.lst, 'base64url')
  const byteIdx = Math.floor(idx / 8)
  const bitIdx = idx % 8

  return ((statusListBytes[byteIdx] >> bitIdx) & 1) === 1
}

Verification Checklist for EUDIW Relying Parties

When receiving a VP Token containing an SD-JWT PID, your verifier MUST check all of the following before trusting any claims:

interface EUDIWVerificationResult {
  // Signature checks
  issuerSignatureValid: boolean    // Step 1: JWT signature valid with trusted issuer key
  issuerInTrustFramework: boolean  // Step 2: Issuer is a registered QTSP/PID Provider

  // Holder binding checks
  holderKeyBindingValid: boolean   // Step 3: KB-JWT signed with key matching cnf.jwk
  nonceMatches: boolean            // Step 4: nonce in KB-JWT matches our request
  sdHashMatches: boolean           // Step 5: sd_hash in KB-JWT matches presentation body

  // Credential validity
  notExpired: boolean              // Step 6: exp is in the future
  notRevoked: boolean              // Step 7: status list check
  disclosuresVerified: boolean     // Step 8: all disclosures have valid hashes in _sd

  // Policy checks
  meetsRequestedClaims: boolean    // Step 9: all requested claims were disclosed
  vctMatchesExpected: boolean      // Step 10: vct matches expected credential type
}

Fail the verification if any check fails. Do not accept partial results.


Nested Claims and Array Disclosures

SD-JWT supports nested selective disclosure for objects and arrays. For the resident_address object in a PID, the issuer can make individual address fields selectively disclosable:

{
  "resident_address": {
    "_sd": [
      "street_hash_here",
      "postal_code_hash_here",
      "locality_hash_here"
    ],
    "country": "DE"
  }
}

This lets the wallet reveal country without disclosing street_address — useful for country-of-residence checks.

For arrays (e.g., driving_privileges), each element can be wrapped in an object with an _sd key:

{
  "driving_privileges": [
    {
      "...": "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwi..."
    }
  ]
}

The "..." key is the SD-JWT reserved key for array element disclosures. This allows hiding individual array elements while preserving the array structure.


Common Implementation Pitfalls

1. Forgetting to verify KB-JWT before trusting claims

The most critical mistake. Without KB-JWT verification, a stolen SD-JWT from one RP session can be replayed to your service. Always verify nonce, aud, sd_hash, and the holder key binding.

// ❌ WRONG: trusting claims before holder binding check
const claims = extractClaims(sdJwt)

// ✅ CORRECT: verify KB-JWT first
const result = await verifySDJWTPresentation(sdJwt, myAudience, myNonce, trustList)
if (!result.holderKeyBindingValid) throw new Error('Holder binding failed')

2. Accepting disclosures not committed to by the issuer

A malicious holder could inject fake disclosures. Always check that every presented disclosure has a matching hash in the issuer JWT's _sd array.

// ❌ WRONG: using decoded disclosures without checking _sd
const decoded = base64urlDecode(disclosure)

// ✅ CORRECT: verify hash is in issuer's _sd
const hash = sha256(disclosure)
if (!issuerPayload._sd.includes(hash)) throw new Error('Unverified disclosure')

3. Confusing SD-JWT with JWT

SD-JWT has a different typ header: vc+sd-jwt for credentials, kb+jwt for Key Binding JWTs. Some JOSE libraries reject JWTs with unknown typ values by default.

// Configure jose to accept SD-JWT types
const { payload } = await jwtVerify(token, key, {
  typ: 'vc+sd-jwt' // explicitly allow this type
})

4. Clock skew without tolerance

EUDIW wallets and servers across EU member states may have clock differences up to 60 seconds. Always set clockTolerance: 60 in your JWT verification.

5. Not checking vct against your expected credential type

A verifier that requests a PID should reject an mDL being presented in its place. Always verify vct matches the credential type you requested in your Presentation Definition.


Testing Your Implementation

Use the EUDIW reference implementation for testing:

// eudi-lib-jvm-sd-jwt-kt (Kotlin) or eudi-lib-js-sd-jwt (TypeScript)
// Reference test vectors are available in the IETF SD-JWT spec appendix

// Test vector: known SD-JWT with known disclosures
const TEST_SD_JWT = `eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK3NkLWp3dCJ9...`
const TEST_DISCLOSURES = [
  "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0",
  "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwiZmFtaWx5X25hbWUiLCJNw7xsbGVyIl0"
]

// Verify against known-good values before testing your own credentials
const result = await verifySDJWTPresentation(
  TEST_SD_JWT + TEST_DISCLOSURES.join('~') + '~' + TEST_KB_JWT,
  'https://test.rp.example',
  'test-nonce-123',
  testTrustList
)
assert(result.claims.given_name === 'Alice')
assert(result.claims.family_name === 'Müller')

What's Next in This Series

This post covered the SD-JWT credential format in depth. The remaining posts in the EU-EIDAS-2-DEVELOPER-2026 series:

If you are building a EUDIW integration and need a sovereign European cloud infrastructure to run it on, sota.io gives you EU-only compute with GDPR compliance by default — no transatlantic data transfers, no Cloud Act exposure.

EU-Native Hosting

Ready to move to EU-sovereign infrastructure?

sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.