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.
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:
- When to sign data with JWS
- When to encrypt it with JWE
- How JWK and JWKS help services publish keys safely
- How to validate tokens correctly in Java
- 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.
| Standard | Purpose | What developers use it for |
|---|---|---|
| JWA | Names cryptographic algorithms | HS256, RS256, ES256, A256GCM |
| JWK | Represents keys as JSON | Publishing public keys, key metadata |
| JWKS | A set of JWKs | Key rotation endpoints like /.well-known/jwks.json |
| JWS | Signed content | JWT access tokens, ID tokens, signed messages |
| JWE | Encrypted content | Sensitive 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.
| Identifier | Meaning | Typical use |
|---|---|---|
HS256 | HMAC with SHA-256 | Internal systems with shared secret |
RS256 | RSA PKCS#1 v1.5 with SHA-256 | Legacy-friendly signed tokens |
PS256 | RSA-PSS with SHA-256 | More modern RSA signing |
ES256 | ECDSA P-256 with SHA-256 | Compact signatures, common in OIDC |
EdDSA | Ed25519 / Ed448 | Modern signing where supported |
RSA-OAEP-256 | RSA OAEP key wrapping | JWE key management |
A256GCM | AES-GCM with 256-bit key | Confidentiality + integrity for JWE payloads |
Rule of thumb: For new systems, prefer
ES256orEdDSAfor signing, andA256GCMfor encryption. UseHS256only 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:
| Field | Meaning |
|---|---|
kty | Key type: RSA, EC, oct, OKP |
kid | Key identifier used during rotation |
use | Intended use: signature or encryption |
alg | Expected algorithm |
crv | Elliptic 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.jsonThe 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:
- A header describing the signing algorithm
- A payload containing claims or business data
- 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
- Build a header with a specific algorithm
- Build a payload with claims
- Sign
Base64Url(header) + "." + Base64Url(payload) - Send the compact token to the caller
JWS Validation Flow
- Parse the token
- Load the expected verification key
- Verify the signature
- Validate claims like
iss,aud,exp,nbf, andscope - 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: nonebypasses in weak implementations- Confusion between symmetric and asymmetric algorithms
- Verifying an
RS256token as though it wereHS256
What to do instead:
- Hardcode the algorithms your service accepts
- Reject
alg: none - Reject unexpected
kidvalues gracefully - Reject tokens with missing or conflicting critical headers
Safe posture: Your code should say "I accept only
RS256from 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 claims | JWS leaves payload readable |
| The token crosses untrusted intermediaries | You want confidentiality in addition to integrity |
| You need client-visible transport but server-only readability | The 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 claims | Added complexity, little value |
| Debugging and interoperability matter more than hidden claims | JWE is harder to inspect |
| Your team has weak key management | Poor 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
| Requirement | Choose | Why |
|---|---|---|
| Detect tampering | JWS | Integrity + authenticity |
| Keep claims confidential | JWE | Encryption + integrity |
| Publish tokens for many verifiers | JWS with asymmetric signing | Public key verification scales well |
| Internal one-hop encrypted payload | JWE with dir | Simple, shared-secret model |
| Sensitive user data in browser-facing token | Usually avoid token bloat; if unavoidable use JWE | Signed-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:
- Signature - Was it signed by a trusted issuer key?
- Issuer (
iss) - Did the expected identity provider mint it? - Audience (
aud) - Was it meant for this API? - Expiration (
exp) - Is it still valid now? - Not before (
nbf) - Is it valid yet? - Scopes / roles - Does it authorize this action?
- 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
- Treating Base64Url as encryption - Anyone can decode a signed JWT payload.
- Accepting arbitrary
algvalues - Never let attacker-supplied headers define verification behavior. - Using
HS256where many verifiers exist - Shared secrets do not scale well across organizational boundaries. - Skipping
audvalidation - A token for one API should not be accepted by another. - Ignoring key rotation design - Without
kidand JWKS support, you turn routine rotation into an outage. - Putting excessive data in tokens - Bigger tokens leak more, are harder to evolve, and often get logged.
- Encrypting when you really need less data - JWE is useful, but it should not be a substitute for better token design.
Quick Decision Guide
| Scenario | Recommended approach |
|---|---|
| Backend API validating third-party access tokens | JWS with asymmetric signing and JWKS-based key lookup |
| Internal one-team service integration | HS256 can work if secret management is disciplined |
| Browser-visible token with sensitive claims | Prefer reducing claims; if unavoidable, consider JWE |
| Multi-service identity platform with key rotation | JWK + JWKS + explicit kid handling |
| High-trust API security review | Validate 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.