1. In one line first
HMAC is a shared secret key used when the server issues an invitation link and the server verifies it. The frontend does not calculate the signature, and the key is not different for each link. There is one key per environment, and what differs by link is the payload containing the pool ID and expiration time.
This article records why we chose HMAC-SHA256 signed tokens while adding expiration periods to workforce pool invitation links, what I initially misunderstood, how we actually integrated the code and separated the deployment settings, and practical notes to avoid repeating the same mistakes. Rather than focusing on theoretical explanations, it centers on why we made those choices at the time and what was wrong.
2. Why We Came to Use This Technology
2.1 Product Requirements
Team leaders use shared links when inviting people to a workforce pool. The existing links contained only the pool ID in the query. When a worker who received the link entered the app, an invitation to that pool was created. The problem was that there was no expiration period. Once a link had spread, it could be opened again indefinitely, and in theory, anyone who knew the URL could change the pool ID to designate another pool.
The product team requested that a new link be created each time the invitation sheet was opened, while allowing multiple workers to use that link together. The link had to expire, the expiration could not be hidden on the client, and the server had to reject it at verification time. It was not enough to hide expired invitations only on the list screen.
2.2 Why We Chose This After Considering the Alternatives
At first, we considered three options side by side.
-
Keep the existing shared link as is and write only a notice saying, “Please enter within a few days.”
-
Put the pool ID and expiration time into the URL using Base64 only. No signature.
-
Use the pool ID and expiration as the payload, attach an HMAC-SHA256 tag using a server secret key, and combine them into a single token.
The first option was the easiest to implement. However, the requirement was to “verify expiration,” so a notice alone could not satisfy it. With the second option, decoding the URL would allow someone to extend the expiration or change the pool ID. It was a structure that could be modified by anyone who knew how. With the third option, only a server with the key could create the same tag, so verification would fail if the URL was modified. It also matched the model in which multiple people share a link. Session cookies or one-time login codes were not suitable for shared links.
Therefore, we chose an HMAC-signed token. We did not use JWT because this token only needed to contain the short claim that it was “an invitation valid for this pool until this time,” rather than identifying the login subject. With only two claims, a manually encoded payload was easier to read.
3. What I Initially Thought and What Was Wrong
There was a large gap between knowing the technology by name and integrating it through deployment. The following are the misunderstandings I had at the time.
3.1 We Need to Create a New Common HmacUtil
At first, I thought we could simply add another shared utility that used standard Base64. When I looked through the code, I found that HMAC-SHA256 already existed in the domain service and that URL-safe Base64 (without padding) was already being used to put it in URLs. The +, /, and = characters in standard Base64 can break query strings. Before creating a new utility, we first needed to match the encoding already in use.
3.2 The App Will Read the Key Automatically If We Put Only the Key Name in the Secret
I thought that if we created a name such as hmac-secret in a Kubernetes Secret object, Spring would read it automatically. In reality, the application yml reads an environment variable name, and that name must be exposed as a pod env variable. If the Secret key differs by even one character from the yml placeholder, the app sees either the default value or an empty value. “Creating a Secret” and “having the pod receive a variable with that name” are separate things.
3.3 The Frontend Calculates the Signature
Since the link creation screen was on the frontend, I thought the frontend would use the key to run HMAC and send it to the API. That would put the secret key in the bundle. The actual flow is the opposite. When the team leader requests an invitation link, the server returns a token string, and the frontend only appends that string to the query. When a worker enters through the link, the same string is sent unchanged to the registration API. Verification is also performed by the server.
3.4 The HMAC Key Is Different for Each Link
If the key changed for every invitation, there would be no practical way to store and manage the keys. There is one key per environment—development, staging, and production—and what differs for each link is the payload. Different pool IDs and different expiration times are signed with the same key.
3.5 A ConfigMap Is Also Required in Staging
If the yml has a default value, development and staging can operate through deployment alone. We use a Secret only in production to override the default value. For this feature alone, there was no need to request a staging ConfigMap first. However, production and development must not use the same key.
4. What HMAC Does Here
HMAC-SHA256 creates a fixed-length tag from a message and a secret key. Only the party with the key can reproduce the same tag. Therefore, even if someone extends the expiration by a day or changes the pool ID in the URL, the server rejects it when the tag does not match. It is not encryption. The payload is visible when decoded. This technology does not prevent the contents from being seen; it prevents visible contents from being changed arbitrarily.
Conceptually, this project’s token consists of two parts: payload.signature. The payload is a string created by encoding the pool ID and expiration time, then encoding the result with URL-safe Base64, while the signature is a string created by encoding the HMAC-SHA256 result for that payload string in the same way. During verification, we recalculate the signature using the received payload and compare it, then check the expiration time. Checking the signature first ensures that we never trust a forged expiration.
5. Implementation Process
5.1 The Name Read by the Configuration
The Spring configuration is roughly as follows. The part after the colon is the default value used when there is no environment variable in the pod. In production, we assume this default is not used and inject an environment variable with the same name into the pod.
banjang:
contact-pool:
invite:
hmac-secret: ${BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET:dev-only-not-for-prod}
expire-hours: ${BANJANG_CONTACT_POOL_INVITE_EXPIRE_HOURS:72}
I often got the ${name:default value} syntax here confused. Kubernetes does not directly modify the yml. When the app starts, if the environment variable BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET exists, that value is used; otherwise, the value after the colon is used. The Secret object’s key name should also be the same as this environment variable name to keep the mapping simple.
5.2 Where the Server Creates the Token (Conceptual Code)
The following is conceptual code that assumes the relevant state is not present in the repository. The actual class and package names may differ from the team’s code. What matters is that the encoding is URL-safe and has no padding, and that the signing target is the “already encoded payload string.”
public String issue(String poolId, Instant expiresAt) {
String payloadJson = "{"p":"" + poolId + "","e":" + expiresAt.getEpochSecond() + "}";
String payload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = base64UrlNoPad(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
return payload + "." + signature;
}
private static String base64UrlNoPad(byte[] src) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(src);
}
5.3 Where the Server Verifies the Token (Conceptual Code)
public InvitePayload verify(String token) {
String[] parts = token.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("malformed token");
}
String payload = parts[0];
String given = parts[1];
String expected = sign(payload); // 발급과 동일한 HMAC
if (!MessageDigest.isEqual(
given.getBytes(StandardCharsets.US_ASCII),
expected.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException("bad signature");
}
InvitePayload body = decode(payload);
if (Instant.now().isAfter(body.expiresAt())) {
throw new IllegalArgumentException("expired");
}
return body;
}
The reason for using MessageDigest.isEqual instead of a string == comparison is the practice of preventing comparison time from being biased toward one side even when the length or contents differ. We did not notice a significant practical difference for invitation links, but this is the appropriate choice for signature comparisons. We check expiration after the signature passes. If we check only expiration without verifying the signature, we could accept a token whose expiration was modified.
5.4 What the Frontend Does
The frontend does not know the key. When the team leader clicks “Create link” on the team leader screen, the server provides a token, and the frontend only appends it as a query parameter. When a worker enters through a deep link, the same string is placed back into the registration API body.
// 반장: 서버가 준 토큰만 붙인다
const shareUrl = `${origin}/job/jobs/pool-invitations?token=${encodeURIComponent(token)}`;
// 일꾼: 받은 토큰 문자열을 그대로 등록에 낸다
await JobWkrFlowApi.registerContactPoolInvitation({ token });
We followed the product decision to create a new token each time the sheet is opened. Because multiple workers can use the same token, it is not a one-time token. “New token” essentially means assigning a new expiration time, and if the previous token has not expired, both tokens may be valid. It is a good idea to confirm this point with the product team once more.
5.5 How the Key Is Actually Delivered During Deployment
The feature works in development and staging using the yml default value. In production, we put the value in a Secret under the key BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET and provide the same name to the pod through the Deployment’s env or envFrom. We do not put the secret key in a ConfigMap. ConfigMaps are often managed as plain text.
# 운영 Secret 값 만들기 (실값은 기록하지 않음)
openssl rand -base64 32
# 개념적인 매핑. 키 이름은 yml placeholder와 동일해야 한다
apiVersion: v1
kind: Secret
metadata:
name: banjang-contact-pool-invite
type: Opaque
stringData:
BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET: "<openssl 결과>"
The request to create a ConfigMap for staging first was premature when considering this feature alone. We can separate the task of overriding the default value with a Secret before the production deployment. If development, staging, and production use the same value, a key leaked in development can be used to forge production links.
6. Points Where We Got Stuck During Implementation
6.1 What Should Be Used as the Signing Target String
We got stuck briefly on whether to HMAC the JSON first and then apply Base64, or to HMAC the Base64 payload string. The issuing and verification sides simply need to use the same approach. We chose the payload string placed in the URL as the signing target. If we sign the JSON bytes again after decoding, it can break when the JSON key order or whitespace differs.
6.2 Using Standard Base64 Directly Breaks Links
A colleague’s snippet used standard Base64. When placed in a query, there are environments where + is interpreted as a space. The padding = is also inconvenient. Only after standardizing on URL-safe encoding with no padding did the token remain intact when shared through messaging apps or text messages.
6.3 Handling Expiration Only as a List Filter Can Be Bypassed
Hiding expired invitations from the list and having the registration API reject expired tokens are different things. If we do only the former, someone who bookmarked an old link can call the API directly. The actual requirement is to compare Instant.now() at verification time with the expiration in the payload.
6.4 The Temptation to Re-sign on the Front End
If you want to display the expiration time on the screen, you can have the front end decode the payload. Decoding it and signing it are different things. The payload is designed to be visible, and the key used to modify and re-sign it must not be kept on the front end. If the expiration time is needed, the server can include expiresAt in the issuance response.
7. What Changed as a Result
-
Invitation links now have an expiration time, and registrations after expiration are rejected by the server.
-
A URL with only the pool ID modified does not pass because its signature is invalid.
-
There is no secret key in the front-end repository. Only the token string is passed along.
-
The operational key was separated into the task of matching the environment variable name with the Secret key.
This article does not state quantitatively how many attacks were reduced. The result of this work is that we now have links whose expiration and forgery the server can detect. Whether to temporarily support the previous link format was a deployment strategy issue, so how many old links to accept was decided separately.
8. What I Learned After Doing This Work
Where to place the code that uses HMAC and where to place the key are different questions. The application already uses the algorithm, and what changes in operations is the pod's environment variable. If you think about writing more code and matching names in the cluster at the same time, it is easy to mistakenly think that creating a Secret is all that needs to be done.
The front end is not a signature calculator. It only passes along the token string. One key does not represent one link, either. There is one key per environment, while the payload differs from link to link. For this feature alone, there was no need to request a stage ConfigMap first, and there was a separate reason to override the default value in production.
Before creating a shared Util, I developed the habit of checking whether HMAC already existed in the domain service. Even when only the encoding differs, it is often not because there is no utility, but because the intention was to use a different alphabet from the one already in use.
9. What I Want to Leave Behind from Practical Experience
-
Even when using signed tokens, do not put the secret key on the client.
-
When creating an operational Secret, use exactly the environment variable name referenced by the yml. Set it to a value different from the development and stage defaults.
-
Operations become impossible if you think about creating a key for every link. A shared key and different payloads are sufficient.
-
With HMAC values placed in URLs, +, /, and = can break links, so first confirm that the encoding is URL-safe and unpadded.
-
Put expiration at the time of verification, not in the list filter.
-
Do not attach actual key values to study notes or the wiki. Leave only the command used to create them.
For example, key generation means placing the result of openssl rand -base64 32 into the Secret value. I did not use a method in which a person writes a long sentence.
10. Related Concepts Worth Reviewing
-
URL-safe Base64: Encoding that keeps a string intact even when placed in links or queries
-
Environment Variables and Kubernetes Secret / ConfigMap: Secrets for confidential information and ConfigMap for plaintext configuration
-
Token Expiration: Rejection at verification time and hiding an item on a list screen are different things
-
Shared Links and Signed Invitation Links: Whether an address is one anyone may open or one that prevents forgery
11. Conclusion
Adding an expiration time to an invitation link looked like a screen-text change. Once I actually did it, questions such as “Who signs it?”, “Where is the key?”, and “Does each link have a different key?” all came up at once. More than HMAC itself, what stayed with me was the flow of keeping secrets off the client and matching environment variable names without getting a single character wrong.
If the same requirement comes up again, I will not choose the algorithm first. I will first write down whether the link is shared, who states its expiration, and how many keys there should be per environment, and then choose HMAC, JWT, or something else. The code in this article has not been uploaded to a repository, so it is conceptual. The class names and configuration keys in the team's code should be checked again against the deployed branch.
Mina