security

Practical Application Security for Developers - Part 4: Modern Authentication and Identity

A practical guide to JOSE, JWS, JWE, JWK, JWKS, and secure token validation in real APIs.

Reading Time: 13 min readAuthor: DeepTechHub
#security#appsec#jwt#oauth2#identity
Practical Application Security for Developers - Part 4: Modern Authentication and Identity

Practical Application Security for Developers - Part 4: Modern Authentication and Identity

In Part 1 we covered cryptographic primitives, in Part 2 we covered public-key systems and certificates, and in Part 3 we saw how TLS secures transport. This part moves up the stack to the problem application teams deal with every day: how to represent identity and trust inside APIs.

If TLS answers "am I talking to the right server over a protected channel?", authentication tokens answer a different question: "who is this caller, what are they allowed to do, and can I trust the claims in front of me?"

This article focuses on the JOSE family of standards and the practical decisions behind modern authentication systems:

  1. When to sign data with JWS
  2. When to encrypt it with JWE
  3. How JWK and JWKS help services publish keys safely
  4. How to validate tokens correctly in Java
  5. Which mistakes actually lead to incidents in production

Why Identity Data Needs Structure

Consider a common flow in a modern application. A user logs in through an identity provider, your frontend receives tokens, and your backend APIs must decide whether to honor them. That sounds straightforward until the details show up:

  • Which key signed this token?
  • Is this token meant for my API or another one?
  • Should the payload be readable by clients, or only by the target service?
  • How do I rotate keys without breaking every consumer?

This is where the JOSE standards help. They give you a shared vocabulary and wire format for signed and encrypted JSON-based security objects.


The JOSE Family at a Glance

JOSE stands for JavaScript Object Signing and Encryption, but despite the name it is not limited to JavaScript. It is a set of interoperable standards used across web apps, mobile apps, API gateways, identity providers, and service-to-service systems.

StandardPurposeWhat developers use it for
JWANames cryptographic algorithmsHS256, RS256, ES256, A256GCM
JWKRepresents keys as JSONPublishing public keys, key metadata
JWKSA set of JWKsKey rotation endpoints like /.well-known/jwks.json
JWSSigned contentJWT access tokens, ID tokens, signed messages
JWEEncrypted contentSensitive claims, confidential tokens, protected payloads

The simplest way to think about the split is this:

  • JWS protects integrity and authenticity
  • JWE protects confidentiality as well
  • JWK/JWKS tells consumers which keys to use
  • JWA gives the algorithms standard names

JWA: The Algorithm Names You Keep Seeing

JWA is the naming registry behind JOSE. These short identifiers are not just labels; they tell other systems exactly which cryptographic primitive is in use.

IdentifierMeaningTypical use
HS256HMAC with SHA-256Internal systems with shared secret
RS256RSA PKCS#1 v1.5 with SHA-256Legacy-friendly signed tokens
PS256RSA-PSS with SHA-256More modern RSA signing
ES256ECDSA P-256 with SHA-256Compact signatures, common in OIDC
EdDSAEd25519 / Ed448Modern signing where supported
RSA-OAEP-256RSA OAEP key wrappingJWE key management
A256GCMAES-GCM with 256-bit keyConfidentiality + integrity for JWE payloads

Rule of thumb: For new systems, prefer ES256 or EdDSA for signing, and A256GCM for encryption. Use HS256 only when both parties are inside your trust boundary and can manage one shared secret safely.


JWK and JWKS: Publishing Keys Without Publishing Secrets

Once you sign tokens with asymmetric cryptography, consumers need your public key. Hardcoding it into every service works for one integration, but it becomes painful once you rotate keys.

That is what JWK and JWKS solve.

A JWK Example

{
  "kty": "EC",
  "kid": "2026-05-auth-key-1",
  "use": "sig",
  "alg": "ES256",
  "crv": "P-256",
  "x": "f83OJ3D2xF4v7xg3f1J9I5oQv5n2tN8q4B9y1A2C3D4",
  "y": "x_FEzRu9cR4Q7l5J9Yv8Qn3mL6uP0r1s2t3u4v5w6x7"
}

Important fields:

FieldMeaning
ktyKey type: RSA, EC, oct, OKP
kidKey identifier used during rotation
useIntended use: signature or encryption
algExpected algorithm
crvElliptic curve name for EC/OKP keys

Why kid Matters

Suppose your identity provider rotates signing keys every 90 days. A token signed yesterday may still be valid today even though a new key is already active. The kid field lets the verifier select the correct public key from the JWKS set without guessing.

A JWKS Endpoint

Most identity providers publish keys through an endpoint like:

https://id.example.com/.well-known/jwks.json

The document usually looks like this:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-2026-01",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

Operational advice: Cache the JWKS for a short period, honor cache headers if available, and be prepared to refresh when you see an unknown kid.


JWS: Signed Claims You Can Trust

Most teams meet JOSE through JWS-backed JWTs. The compact serialization has three parts:

Base64Url(header).Base64Url(payload).Base64Url(signature)

That means a JWS is:

  1. A header describing the signing algorithm
  2. A payload containing claims or business data
  3. A signature proving the content was not modified

Important Constraint

JWS is not encryption. Anyone who holds the token can Base64Url-decode the header and payload. The signature stops tampering; it does not hide the data.

That makes JWS ideal for:

  • Access tokens
  • ID tokens
  • Signed webhook payloads
  • Internal messages where the contents may be visible but must not be modified

It is a bad fit for:

  • SSNs or payment data in browser-visible tokens
  • Secrets you do not want intermediaries or clients to read

A Typical JWT Payload

{
  "iss": "https://id.example.com",
  "sub": "user-123",
  "aud": "orders-api",
  "scope": "orders.read orders.write",
  "iat": 1770000000,
  "exp": 1770000300
}

JWS Creation Flow

  1. Build a header with a specific algorithm
  2. Build a payload with claims
  3. Sign Base64Url(header) + "." + Base64Url(payload)
  4. Send the compact token to the caller

JWS Validation Flow

  1. Parse the token
  2. Load the expected verification key
  3. Verify the signature
  4. Validate claims like iss, aud, exp, nbf, and scope
  5. Only then trust the payload

Production rule: Signature verification alone is not enough. A correctly signed token with the wrong audience is still invalid for your API.


Signing and Verifying JWS in Java

Nimbus JOSE + JWT is one of the most common Java libraries for JOSE work.

Maven Dependency

<dependency>
  <groupId>com.nimbusds</groupId>
  <artifactId>nimbus-jose-jwt</artifactId>
  <version>9.39.3</version>
</dependency>

HS256 Example: Internal Service Token

Use this pattern only when both producer and consumer can safely share the same secret.

import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
 
import java.text.ParseException;
import java.util.Map;
 
public class Hs256TokenUtil {
 
    public static String sign(byte[] secret) throws Exception {
        JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256)
                .type(JOSEObjectType.JWT)
                .keyID("internal-hmac-key-1")
                .build();
 
        Payload payload = new Payload(Map.of(
                "iss", "orders-service",
                "sub", "job-runner",
                "aud", "billing-service",
                "scope", "invoice.write"
        ));
 
        JWSObject jwsObject = new JWSObject(header, payload);
        jwsObject.sign(new MACSigner(secret));
        return jwsObject.serialize();
    }
 
    public static boolean verify(String token, byte[] secret)
            throws ParseException, Exception {
        JWSObject jwsObject = JWSObject.parse(token);
        return jwsObject.verify(new MACVerifier(secret));
    }
}

RS256 Example: Public Verification Across Services

This is the more typical pattern for identity systems, because signers keep the private key and verifiers only need the public key.

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.crypto.RSASSAVerifier;
 
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;
 
public class Rs256TokenExample {
 
    public static void main(String[] args) throws Exception {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        KeyPair keyPair = generator.generateKeyPair();
 
        JWSObject jwsObject = new JWSObject(
                new JWSHeader.Builder(JWSAlgorithm.RS256)
                        .keyID("rsa-signing-key-2026-05")
                        .build(),
                new Payload(Map.of(
                        "iss", "https://id.example.com",
                        "sub", "user-123",
                        "aud", "inventory-api"
                ))
        );
 
        jwsObject.sign(new RSASSASigner(keyPair.getPrivate()));
        String token = jwsObject.serialize();
 
        JWSObject parsed = JWSObject.parse(token);
        boolean valid = parsed.verify(
                new RSASSAVerifier((RSAPublicKey) keyPair.getPublic())
        );
 
        System.out.println(valid);
        System.out.println(parsed.getPayload().toString());
    }
}

The alg Problem and Other Header-Level Attacks

One of the oldest JOSE failures is not cryptography; it is trust in attacker-controlled headers.

If your verifier accepts whatever algorithm the incoming header asks for, you are letting untrusted input decide your security policy. Historically, this led to issues like:

  • alg: none bypasses in weak implementations
  • Confusion between symmetric and asymmetric algorithms
  • Verifying an RS256 token as though it were HS256

What to do instead:

  • Hardcode the algorithms your service accepts
  • Reject alg: none
  • Reject unexpected kid values gracefully
  • Reject tokens with missing or conflicting critical headers

Safe posture: Your code should say "I accept only RS256 from this issuer" rather than "tell me what algorithm you used and I will try it."


JWE: When Signed Data Is Not Private Enough

Sometimes integrity is not enough. Consider these examples:

  • A frontend token carries too much profile information
  • A B2B integration sends onboarding data containing legal identifiers
  • An internal event contains secrets or sensitive attributes you do not want visible in logs or proxies

That is where JWE fits. It adds confidentiality and integrity by encrypting the payload.

The compact serialization is usually represented as:

Base64Url(header).Base64Url(encryptedKey).Base64Url(iv).Base64Url(ciphertext).Base64Url(tag)

When to Use JWE

Use JWE when...Why
The payload contains sensitive claimsJWS leaves payload readable
The token crosses untrusted intermediariesYou want confidentiality in addition to integrity
You need client-visible transport but server-only readabilityThe client can carry it without understanding it

When Not to Use It

Avoid JWE when...Why
TLS already protects transport and the token contains only non-sensitive claimsAdded complexity, little value
Debugging and interoperability matter more than hidden claimsJWE is harder to inspect
Your team has weak key managementPoor key handling cancels out cryptographic gains

Encrypting and Decrypting JWE in Java

Direct Encryption with Shared Secret

This is suitable for tightly controlled internal systems.

import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.DirectDecrypter;
import com.nimbusds.jose.crypto.DirectEncrypter;
 
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
 
public class DirectJweExample {
 
    public static void main(String[] args) throws Exception {
        KeyGenerator generator = KeyGenerator.getInstance("AES");
        generator.init(256);
        SecretKey secretKey = generator.generateKey();
 
        JWEObject jweObject = new JWEObject(
                new JWEHeader.Builder(JWEAlgorithm.DIR, EncryptionMethod.A256GCM)
                        .contentType("JWT")
                        .build(),
                new Payload("{\"accountId\":\"A-100\",\"tier\":\"gold\"}")
        );
 
        jweObject.encrypt(new DirectEncrypter(secretKey));
        String compact = jweObject.serialize();
 
        JWEObject parsed = JWEObject.parse(compact);
        parsed.decrypt(new DirectDecrypter(secretKey));
 
        System.out.println(parsed.getPayload().toString());
    }
}

RSA-OAEP + AES-GCM for Distributed Systems

This is usually the better fit when producers and consumers should not share a single secret.

import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSAEncrypter;
 
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
 
public class RsaJweExample {
 
    public static void main(String[] args) throws Exception {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        KeyPair keyPair = generator.generateKeyPair();
 
        JWEObject jweObject = new JWEObject(
                new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
                        .keyID("recipient-key-1")
                        .build(),
                new Payload("{\"user\":\"alice\",\"risk\":\"high\"}")
        );
 
        jweObject.encrypt(new RSAEncrypter((RSAPublicKey) keyPair.getPublic()));
        String token = jweObject.serialize();
 
        JWEObject parsed = JWEObject.parse(token);
        parsed.decrypt(new RSADecrypter((RSAPrivateKey) keyPair.getPrivate()));
 
        System.out.println(parsed.getPayload().toString());
    }
}

JWS vs JWE: Practical Decision Guide

RequirementChooseWhy
Detect tamperingJWSIntegrity + authenticity
Keep claims confidentialJWEEncryption + integrity
Publish tokens for many verifiersJWS with asymmetric signingPublic key verification scales well
Internal one-hop encrypted payloadJWE with dirSimple, shared-secret model
Sensitive user data in browser-facing tokenUsually avoid token bloat; if unavoidable use JWESigned-only JWTs are readable

In many systems the better design is not "encrypt the token" but "put less data in the token." Keep tokens small and move richer profile lookups server-side.


What a Real API Must Validate

Developers often stop at "signature verified." That is necessary, but it is not the whole job.

For bearer tokens, the verifier should check at least:

  1. Signature - Was it signed by a trusted issuer key?
  2. Issuer (iss) - Did the expected identity provider mint it?
  3. Audience (aud) - Was it meant for this API?
  4. Expiration (exp) - Is it still valid now?
  5. Not before (nbf) - Is it valid yet?
  6. Scopes / roles - Does it authorize this action?
  7. Key ID (kid) - Can you match it to a trusted key?

Minimal Claim Validation Example

import com.nimbusds.jwt.SignedJWT;
 
import java.time.Instant;
import java.util.Date;
import java.util.List;
 
public class TokenClaimsValidator {
 
    public static void validate(SignedJWT jwt) throws Exception {
        var claims = jwt.getJWTClaimsSet();
 
        if (!"https://id.example.com".equals(claims.getIssuer())) {
            throw new SecurityException("Unexpected issuer");
        }
 
        if (!claims.getAudience().contains("orders-api")) {
            throw new SecurityException("Wrong audience");
        }
 
        Date exp = claims.getExpirationTime();
        if (exp == null || exp.toInstant().isBefore(Instant.now())) {
            throw new SecurityException("Token expired");
        }
 
        List<String> scopes = List.of(claims.getStringClaim("scope").split(" "));
        if (!scopes.contains("orders.read")) {
            throw new SecurityException("Missing scope");
        }
    }
}

Common Mistakes That Break Token Security

  1. Treating Base64Url as encryption - Anyone can decode a signed JWT payload.
  2. Accepting arbitrary alg values - Never let attacker-supplied headers define verification behavior.
  3. Using HS256 where many verifiers exist - Shared secrets do not scale well across organizational boundaries.
  4. Skipping aud validation - A token for one API should not be accepted by another.
  5. Ignoring key rotation design - Without kid and JWKS support, you turn routine rotation into an outage.
  6. Putting excessive data in tokens - Bigger tokens leak more, are harder to evolve, and often get logged.
  7. Encrypting when you really need less data - JWE is useful, but it should not be a substitute for better token design.

Quick Decision Guide

ScenarioRecommended approach
Backend API validating third-party access tokensJWS with asymmetric signing and JWKS-based key lookup
Internal one-team service integrationHS256 can work if secret management is disciplined
Browser-visible token with sensitive claimsPrefer reducing claims; if unavoidable, consider JWE
Multi-service identity platform with key rotationJWK + JWKS + explicit kid handling
High-trust API security reviewValidate signature, issuer, audience, expiry, and scopes every time

What's Next

Once you understand how tokens are signed, encrypted, and validated, the next question is broader: how do those tokens fit into real login and authorization flows?

That is where OAuth 2.0 and OpenID Connect come in. In Part 5, we move from token structure to end-to-end protocol flows: authorization code, PKCE, refresh tokens, ID tokens, and single sign-on design in real systems.

Continue Learning

Explore more guides and resources to deepen your knowledge.