Skip to content

Commit 735062d

Browse files
rameerezclaude
andcommitted
Add public key token storage for non-revocable publishable keys
Non-revocable keys create a UX problem: if users lose the token, they're locked out forever since they can't delete and recreate the key. This is especially problematic with limit: 1 configurations. This adds a `public: true` option for key types that stores the plaintext token in metadata, allowing users to view it again in the dashboard. Security: Token storage ONLY happens when BOTH conditions are met: - public: true is set in the key type configuration - revocable: false is set (non-revocable keys only) This double-check ensures secret keys are NEVER stored, even if someone accidentally sets public: true on them (since secret keys are revocable by default). Changes: - Add public_key_type? and viewable_token methods to ApiKey model - Store token in metadata during creation for public keys - Add Show/Copy buttons in dashboard for public keys - Document public option in configuration, initializer, and README - Add 20 tests including 10 security tests verifying secret keys are never stored under any circumstances Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ffa9f09 commit 735062d

File tree

6 files changed

+471
-2
lines changed

6 files changed

+471
-2
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,67 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
555555

556556
The dashboard UI automatically hides the revoke button for non-revocable keys.
557557

558+
### Public Keys (Viewable Tokens)
559+
560+
#### The Problem: Non-Revocable Key Lockout
561+
562+
Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove.
563+
564+
This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys.
565+
566+
#### The Solution: Storing Public Keys
567+
568+
For publishable keys—which are *designed* to be embedded in client-side code and distributed apps—there's no security benefit to hiding the token. These keys are meant to be public! Stripe, for example, lets you view your publishable key anytime in the dashboard.
569+
570+
The `public: true` option stores the plaintext token in metadata so users can view it again:
571+
572+
```ruby
573+
config.key_types = {
574+
publishable: {
575+
prefix: "pk",
576+
permissions: %w[read validate],
577+
revocable: false,
578+
public: true, # Store token for later viewing
579+
limit: 1
580+
},
581+
secret: {
582+
prefix: "sk",
583+
permissions: :all
584+
# public: false (default) - NEVER store secret keys!
585+
}
586+
}
587+
```
588+
589+
#### Security: Why This is Safe
590+
591+
> [!IMPORTANT]
592+
> The `public` option only works when BOTH conditions are met:
593+
> - `public: true` is set in the key type configuration
594+
> - `revocable: false` is set (non-revocable keys only)
595+
596+
This double-check is a deliberate safety measure:
597+
598+
1. **Secret keys are NEVER stored** — Even if you accidentally set `public: true` on a secret key type, the gem checks for `revocable: false` as well. Secret keys are revocable by default, so they're protected.
599+
600+
2. **Revocable keys are NEVER stored** — If a key can be revoked, users can always delete it and create a new one. There's no lockout risk, so no need to store the token.
601+
602+
3. **Only truly public keys are stored** — Publishable keys with limited permissions, designed for client-side embedding, are the only keys that get stored. These tokens provide no security benefit when hidden—they're meant to be distributed.
603+
604+
> [!WARNING]
605+
> ⚠️ **Never set `public: true` on secret keys or any key type with sensitive permissions.** The gem prevents this by requiring `revocable: false`, but you should also never configure it that way.
606+
607+
When a key is public, the dashboard shows a "Show" button to reveal the full token:
608+
609+
```ruby
610+
pk = user.create_api_key!(key_type: :publishable)
611+
pk.public_key_type? # => true
612+
pk.viewable_token # => "pk_test_abc123..." (the full token)
613+
614+
sk = user.create_api_key!(key_type: :secret)
615+
sk.public_key_type? # => false
616+
sk.viewable_token # => nil (not stored)
617+
```
618+
558619
### Environment Isolation
559620

560621
With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:

app/views/api_keys/keys/_key_row.html.erb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,16 @@
2222
</span>
2323
<% end %>
2424
</td>
25-
<td><code><%= key.masked_token %></code></td>
25+
<td>
26+
<code><%= key.masked_token %></code>
27+
<% if key.public_key_type? && key.viewable_token.present? %>
28+
<button type="button" onclick="this.nextElementSibling.style.display='inline'; this.style.display='none';" style="margin-left: 8px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Show full token">Show</button>
29+
<span style="display: none;">
30+
<code style="word-break: break-all;"><%= key.viewable_token %></code>
31+
<button type="button" onclick="navigator.clipboard.writeText('<%= key.viewable_token %>'); alert('Copied!');" style="margin-left: 4px; font-size: 0.75em; padding: 2px 6px; cursor: pointer;" title="Copy to clipboard">Copy</button>
32+
</span>
33+
<% end %>
34+
</td>
2635

2736

2837
<td title="<%= key.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %>">

lib/api_keys/configuration.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ class Configuration
6363
# - :permissions [Array<String>, :all] Scope ceiling for this type
6464
# - :revocable [Boolean] Whether keys can be revoked (default: true)
6565
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
66+
# - :public [Boolean] If true AND revocable: false, store plaintext token in
67+
# metadata so it can be viewed again in dashboard. Use ONLY for publishable
68+
# keys that are designed to be embedded in distributed apps. (default: false)
6669
# @example
6770
# config.key_types = {
68-
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, limit: 1 },
71+
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
6972
# secret: { prefix: "sk", permissions: :all }
7073
# }
7174
#

lib/api_keys/models/api_key.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@ def environment_config
102102
ApiKeys.configuration.environments&.dig(environment.to_sym)
103103
end
104104

105+
# Returns true if this key type is configured as public AND non-revocable.
106+
# Only these keys have their plaintext token stored in metadata for later viewing.
107+
# This is used for publishable keys that are designed to be embedded in distributed apps.
108+
def public_key_type?
109+
return false if key_type.blank?
110+
config = key_type_config
111+
return false if config.nil?
112+
config[:public] == true && config[:revocable] == false
113+
end
114+
115+
# Returns the stored plaintext token for public, non-revocable keys.
116+
# Returns nil for all other key types (the token is only available at creation time).
117+
# @return [String, nil] The full plaintext token, or nil if not stored
118+
def viewable_token
119+
return nil unless public_key_type?
120+
metadata&.dig("token")
121+
end
122+
105123
# Override destroy to prevent destroying non-revocable keys
106124
def destroy
107125
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
@@ -237,6 +255,14 @@ def generate_token_and_digest
237255
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
238256
self.expires_at = ApiKeys.configuration.expire_after.from_now
239257
end
258+
259+
# Store plaintext token in metadata for public, non-revocable keys.
260+
# This allows users to view the token again in the dashboard.
261+
# SECURITY: Only do this for keys explicitly configured as public: true
262+
# AND revocable: false (e.g., publishable keys for distributed apps).
263+
if public_key_type?
264+
self.metadata = (self.metadata || {}).merge("token" => @token)
265+
end
240266
end
241267

242268
# == Validation Helpers ==

lib/generators/api_keys/templates/initializer.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,24 @@
111111
# - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted
112112
# - revocable: Whether keys of this type can be revoked/deleted (default: true)
113113
# - limit: Max keys of this type per owner per environment (nil = unlimited)
114+
# - public: If true AND revocable: false, stores plaintext token in metadata
115+
# so it can be viewed again in the dashboard. Use ONLY for publishable
116+
# keys designed to be embedded in distributed apps. (default: false)
117+
# SECURITY: NEVER set public: true on secret keys!
114118
#
115119
# config.key_types = {
116120
# publishable: {
117121
# prefix: "pk", # → pk_test_, pk_live_
118122
# permissions: %w[read validate], # Can ONLY have these scopes
119123
# revocable: false, # Cannot be revoked - protects deployed apps!
124+
# public: true, # Store token for later viewing in dashboard
120125
# limit: 1 # Only 1 publishable key per environment
121126
# },
122127
# secret: {
123128
# prefix: "sk", # → sk_test_, sk_live_
124129
# permissions: :all # No scope restrictions
125130
# # revocable: true (default)
131+
# # public: false (default) - NEVER store secret keys!
126132
# # limit: nil (default = unlimited)
127133
# }
128134
# }

0 commit comments

Comments
 (0)